Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/apps-script-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
node-version: "24"
cache: npm
- run: npm ci
- run: npm audit --audit-level=moderate
- run: npm run validate
- name: Configure clasp credentials
env:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ jobs:
node-version: '24'
cache: npm
- run: npm ci
- run: npm audit --audit-level=moderate
- run: npm run assets
- run: npm run validate
185 changes: 139 additions & 46 deletions Code.gs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

const API_BASE_URL = 'https://api.oilpriceapi.com/v1';
const ADDON_VERSION = '1.3.0';
const ADDON_VERSION = '1.3.1';
const KEY_PROPERTY = 'OILPRICEAPI_KEY';
const LAST_DIAGNOSTIC_PROPERTY = 'OILPRICEAPI_LAST_DIAGNOSTIC';
const CACHE_GENERATION_PROPERTY = 'OILPRICEAPI_CACHE_GENERATION';
Expand All @@ -28,6 +28,7 @@ const CACHE_TTL_SECONDS = {
};
const MAX_CACHE_TTL_SECONDS = 21600;
let cachedGeneration_ = null;
let cachedCacheScope_ = null;

// Keep generic worksheet requests aligned with the Excel add-in's reviewed
// endpoint catalog. Add endpoints deliberately after API-shape tests exist.
Expand Down Expand Up @@ -193,10 +194,7 @@ function getApiKey_() {
const spreadsheetKey = spreadsheetKeyProperty
? userProperties.getProperty(spreadsheetKeyProperty)
: null;
if (spreadsheetKey) return spreadsheetKey;

// Migration fallback for keys saved by releases before Apps Script version 6.
return userProperties.getProperty(KEY_PROPERTY);
return spreadsheetKey || null;
}

function requireApiKey_() {
Expand Down Expand Up @@ -236,6 +234,7 @@ function saveApiKey(apiKey) {
userProperties.setProperty(`${CACHE_GENERATION_PROPERTY}:${getActiveSpreadsheetId_()}`, cacheGeneration);
userProperties.deleteProperty(KEY_PROPERTY);
cachedGeneration_ = null;
cachedCacheScope_ = null;
return {
success: true,
message: 'API key saved for this spreadsheet in Apps Script properties.'
Expand All @@ -257,6 +256,7 @@ function deleteApiKey() {
userProperties.deleteProperty(KEY_PROPERTY);
userProperties.deleteProperty(LAST_DIAGNOSTIC_PROPERTY);
cachedGeneration_ = null;
cachedCacheScope_ = null;
return {
success: true,
message: 'Stored spreadsheet API key and request diagnostic deleted.'
Expand Down Expand Up @@ -312,38 +312,111 @@ function responseHeader_(response, name) {
return key && typeof headers[key] === 'string' ? headers[key].slice(0, 128) : '';
}

function cacheGeneration_() {
if (cachedGeneration_ !== null) return cachedGeneration_;
function cacheContext_() {
if (cachedGeneration_ !== null && cachedCacheScope_ !== null) {
return { generation: cachedGeneration_, scope: cachedCacheScope_ };
}
const documentProperties = getDocumentProperties_();
const documentKey = documentProperties
? documentProperties.getProperty(KEY_PROPERTY)
: null;
const documentGeneration = documentProperties
? documentProperties.getProperty(CACHE_GENERATION_PROPERTY)
: null;
if (documentGeneration) {
if (documentKey && documentGeneration && documentGeneration !== 'legacy') {
cachedGeneration_ = documentGeneration;
return cachedGeneration_;
cachedCacheScope_ = 'document';
return { generation: cachedGeneration_, scope: cachedCacheScope_ };
}

const spreadsheetId = getActiveSpreadsheetId_();
let userGeneration = null;
if (spreadsheetId) {
try {
userGeneration = PropertiesService.getUserProperties().getProperty(
`${CACHE_GENERATION_PROPERTY}:${spreadsheetId}`
);
} catch (error) {
// Unknown credential contexts must remain isolated in the user cache.
}
}
cachedGeneration_ = userGeneration || 'legacy';
cachedCacheScope_ = 'user';
return { generation: cachedGeneration_, scope: cachedCacheScope_ };
}

function cacheGeneration_() {
return cacheContext_().generation;
}

function effectiveCacheScope_(requestedScope) {
if (requestedScope === 'user') return 'user';
return cacheContext_().scope;
}

function nextCacheGeneration_(previousGeneration) {
const previous = Number(previousGeneration);
return String(
Math.max(Date.now(), Number.isFinite(previous) ? previous + 1 : 0)
);
}

function invalidateCacheGeneration_() {
const currentContext = cacheContext_();
const generation = nextCacheGeneration_(currentContext.generation);
let activeScopeInvalidated = false;
if (currentContext.scope === 'document') {
const documentProperties = getDocumentProperties_();
if (documentProperties) {
try {
documentProperties.setProperty(CACHE_GENERATION_PROPERTY, generation);
activeScopeInvalidated = true;
} catch (error) {
// A fallback generation cannot invalidate an active document cache.
}
}
}
const spreadsheetId = getActiveSpreadsheetId_();
if (!spreadsheetId) {
cachedGeneration_ = 'legacy';
return cachedGeneration_;
if (spreadsheetId) {
try {
PropertiesService.getUserProperties().setProperty(
`${CACHE_GENERATION_PROPERTY}:${spreadsheetId}`,
generation
);
if (currentContext.scope === 'user') activeScopeInvalidated = true;
} catch (error) {
// A confirmed document write remains sufficient for document scope.
}
}
cachedGeneration_ = null;
cachedCacheScope_ = null;
if (!activeScopeInvalidated) {
throw makeError_(
'RETRY_LATER',
'Connection succeeded, but cached worksheet state could not be refreshed. Run Test Connection again.'
);
}
cachedGeneration_ = PropertiesService.getUserProperties().getProperty(
`${CACHE_GENERATION_PROPERTY}:${spreadsheetId}`
) || 'legacy';
return cachedGeneration_;
}

function stableCacheHash_(value) {
const text = String(value || '');
const digest = stableCacheDigest_(text);
const slug = text.replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 96);
return `${slug}_${digest}`;
}

function stableCacheDigest_(value) {
let hash = 2166136261;
const text = String(value || '');
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
const digest = (hash >>> 0).toString(36);
const slug = text.replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 96);
return `${slug}_${digest}`;
return (hash >>> 0).toString(36);
}

function combineCacheDigests_(primaryDigest, secondaryDigest) {
return `${primaryDigest}_${secondaryDigest}`;
}

function requestBlockKeys_(path) {
Expand Down Expand Up @@ -686,43 +759,57 @@ function validatePriceRecord_(record, subject) {
};
}

function cacheStore_(scope) {
function cacheStoreContext_(scope) {
if (typeof CacheService === 'undefined') return null;
if (scope === 'document' && typeof CacheService.getDocumentCache === 'function') {
const effectiveScope = effectiveCacheScope_(scope || 'document');
if (
effectiveScope === 'document' &&
typeof CacheService.getDocumentCache === 'function'
) {
try {
const documentCache = CacheService.getDocumentCache();
if (documentCache) return documentCache;
if (documentCache) return { store: documentCache, scope: 'document' };
} catch (error) {
// Fall through to the per-user cache when the document cache is unavailable.
}
}
try {
return typeof CacheService.getUserCache === 'function'
? CacheService.getUserCache()
: null;
if (typeof CacheService.getUserCache !== 'function') return null;
const userCache = CacheService.getUserCache();
return userCache ? { store: userCache, scope: 'user' } : null;
} catch (error) {
return null;
}
}

function namespacedCacheKey_(cacheKey) {
return `opa_${cacheGeneration_()}_${cacheKey}`;
function namespacedCacheKey_(cacheKey, cacheScope) {
if (cacheScope === 'user') {
const spreadsheetId = getActiveSpreadsheetId_();
if (!spreadsheetId) return null;
const spreadsheetHash = combineCacheDigests_(
stableCacheDigest_(spreadsheetId),
stableCacheDigest_(`sheet:${spreadsheetId}`)
);
return `opa_u_${spreadsheetHash}_${cacheGeneration_()}_${cacheKey}`;
}
return `opa_d_${cacheGeneration_()}_${cacheKey}`;
}

function getCachedValue_(cacheKey, maxAgeSeconds, scope) {
try {
const cache = cacheStore_(scope || 'document');
if (!cache || typeof cache.get !== 'function') return null;
const namespacedKey = namespacedCacheKey_(cacheKey);
const raw = cache.get(namespacedKey);
const cacheContext = cacheStoreContext_(scope || 'document');
if (!cacheContext || typeof cacheContext.store.get !== 'function') return null;
const namespacedKey = namespacedCacheKey_(cacheKey, cacheContext.scope);
if (!namespacedKey) return null;
const raw = cacheContext.store.get(namespacedKey);
if (!raw) return null;

let envelope;
try {
envelope = JSON.parse(raw);
} catch (error) {
try {
cache.remove(namespacedKey);
cacheContext.store.remove(namespacedKey);
} catch (removeError) {
// An invalid entry can expire naturally if removal is unavailable.
}
Expand All @@ -735,7 +822,7 @@ function getCachedValue_(cacheKey, maxAgeSeconds, scope) {
Date.now() - envelope.cachedAt > maxAgeSeconds * 1000
) {
try {
cache.remove(namespacedKey);
cacheContext.store.remove(namespacedKey);
} catch (removeError) {
// An expired entry can expire naturally if removal is unavailable.
}
Expand All @@ -750,8 +837,12 @@ function getCachedValue_(cacheKey, maxAgeSeconds, scope) {

function putCachedValue_(cacheKey, value, ttlSeconds, scope) {
try {
cacheStore_(scope || 'document').put(
namespacedCacheKey_(cacheKey),
const cacheContext = cacheStoreContext_(scope || 'document');
if (!cacheContext || typeof cacheContext.store.put !== 'function') return;
const namespacedKey = namespacedCacheKey_(cacheKey, cacheContext.scope);
if (!namespacedKey) return;
cacheContext.store.put(
namespacedKey,
JSON.stringify({ cachedAt: Date.now(), value }),
Math.min(MAX_CACHE_TTL_SECONDS, Math.max(1, Math.round(ttlSeconds)))
);
Expand All @@ -762,9 +853,10 @@ function putCachedValue_(cacheKey, value, ttlSeconds, scope) {

function removeCachedValue_(cacheKey, scope) {
try {
const cache = cacheStore_(scope || 'document');
if (cache && typeof cache.remove === 'function') {
cache.remove(namespacedCacheKey_(cacheKey));
const cacheContext = cacheStoreContext_(scope || 'document');
if (cacheContext && typeof cacheContext.store.remove === 'function') {
const namespacedKey = namespacedCacheKey_(cacheKey, cacheContext.scope);
if (namespacedKey) cacheContext.store.remove(namespacedKey);
}
} catch (error) {
// Request-block cleanup must not replace a valid live response.
Expand Down Expand Up @@ -963,6 +1055,7 @@ function testConnection() {
{ bypassBlock: true }
);
validatePriceRecord_(extractPriceRecords_(body, 'price')[0], 'Price record');
invalidateCacheGeneration_();
return { success: true, message: 'Connection and response schema verified.' };
} catch (error) {
return { success: false, message: error.message };
Expand All @@ -971,20 +1064,20 @@ function testConnection() {

function getUserInfo() {
if (!getApiKey_()) {
return { tier: 'none', limit: null, used: null };
return { tier: 'none', limit: null, used: null, window: null };
}
try {
const body = requestJson_('/users/me', getApiKey_());
const data = body.data && typeof body.data === 'object' ? body.data : body;
const tier = typeof data.tier === 'string' ? data.tier : (typeof data.plan === 'string' ? data.plan : null);
const limit = Number(data.request_limit);
const used = Number(data.requests_this_month);
if (!tier || !Number.isFinite(limit) || !Number.isFinite(used)) {
return { tier: 'unknown', limit: null, used: null };
}
return { tier, limit, used };
return {
tier: tier || 'unknown',
limit: null,
used: null,
window: null
};
} catch (error) {
return { tier: 'unknown', limit: null, used: null, message: error.message };
return { tier: 'unknown', limit: null, used: null, window: null, message: error.message };
}
}

Expand Down
23 changes: 14 additions & 9 deletions DEPLOYMENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@ test installation, screenshots, and submission require the publisher account.
## Current release gate

- Public runtime version: `1.2.2`
- Repository release candidate: `1.3.0`
- Repository release candidate: `1.3.1`
- Production Apps Script ID:
`1rlVWvciYu-wzqnY009I3oW-08ZPazYK1snrrMg9NNY7c5WBSkUK8W2Hb`
- Current immutable Apps Script version: `11` (runtime `1.2.2`, cut
2026-07-29 18:01 EDT / 22:01 UTC, immediately after PR #22 merged).
Verified 2026-07-31 in Apps Script Project History; version 11's `Code.gs`
reads `ADDON_VERSION = '1.2.2'`.
- Latest immutable Apps Script version: `12` (runtime `1.3.0`, created by
workflow run `31489545156` on 2026-08-11). Version 12 failed the subsequent
user-scoped cache-isolation review and was never published. The Marketplace
remains on version `11` (runtime `1.2.2`).
- GitHub production release workflow:
`https://github.com/OilpriceAPI/google-sheets-addin/actions/workflows/apps-script-release.yml`
Note: versions 9-11 were cut locally with `npm run deploy:version`, not by
this workflow, whose only run to date is 2026-07-24.
Note: versions 9-11 were cut locally with `npm run deploy:version`; version
12 was cut by the governed workflow above.
- Marketplace status: **published** at
`https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434`.
The public listing points to immutable Apps Script version 11 (`1.2.2`).
Do not select the `1.3.0` candidate until its exact immutable version passes
Do not select the `1.3.1` candidate until its exact immutable version passes
an installed Marketplace formula smoke.
- Runtime push/version and local deployment checks: complete
- Marketplace review receipt and a real 1280x800 screenshot: complete
Expand Down Expand Up @@ -191,7 +191,7 @@ screenshots until they have been reviewed for secrets and customer data.
After the smoke passes against the exact pushed source:

```bash
npm run deploy:version -- "OilPriceAPI for Google Sheets 1.3.0 customer-path recovery"
npm run deploy:version -- "OilPriceAPI for Google Sheets 1.3.1 cache-isolation recovery"
npm run deploy:list
```

Expand Down Expand Up @@ -272,5 +272,10 @@ Push and smoke-test the new code, create a new Apps Script version, then update
the version number on the Marketplace SDK App Configuration page. Do not create
a new app integration or change the Script ID.

Runtime `1.3.1` retires unscoped API keys saved by releases before Apps Script
version 6. Those legacy values cannot be attributed to a particular spreadsheet
and are therefore not safe to use. Affected users must open each intended
spreadsheet and save the key again from **OilPriceAPI > Configure API Key**.

If scopes change, update all three scope lists and complete any required OAuth
reverification before publishing the new version.
Loading