diff --git a/Code.gs b/Code.gs
index 1916d63..b8eb4c4 100644
--- a/Code.gs
+++ b/Code.gs
@@ -1,23 +1,33 @@
/**
* OilPriceAPI Google Sheets add-on.
*
- * Google Workspace Marketplace publication is pending.
+ * Public listing: https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434
* Product facts: https://api.oilpriceapi.com/product-facts.json
*/
const API_BASE_URL = 'https://api.oilpriceapi.com/v1';
-const ADDON_VERSION = '1.2.2';
+const ADDON_VERSION = '1.3.0';
const KEY_PROPERTY = 'OILPRICEAPI_KEY';
const LAST_DIAGNOSTIC_PROPERTY = 'OILPRICEAPI_LAST_DIAGNOSTIC';
+const CACHE_GENERATION_PROPERTY = 'OILPRICEAPI_CACHE_GENERATION';
const MAX_BATCH_CODES = 25;
const PRICING_URL = 'https://www.oilpriceapi.com/pricing';
const SIGNUP_URL = 'https://www.oilpriceapi.com/auth/signup';
+const MARKETPLACE_URL = 'https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434';
const CACHE_TTL_SECONDS = {
latest: 300,
+ latestFree: 3600,
+ latestEnterprise: 60,
+ generic: 300,
+ catalog: 21600,
+ bunker: 300,
+ exchangeRates: 3600,
history: 3600,
futures: 300,
rigCount: 3600
};
+const MAX_CACHE_TTL_SECONDS = 21600;
+let cachedGeneration_ = null;
// Keep generic worksheet requests aligned with the Excel add-in's reviewed
// endpoint catalog. Add endpoints deliberately after API-shape tests exist.
@@ -133,9 +143,10 @@ function showAbout() {
const ui = SpreadsheetApp.getUi();
ui.alert(
'OilPriceAPI for Google Sheets™',
- `Version ${ADDON_VERSION}\n\n` +
+ `Runtime version: ${ADDON_VERSION}\n\n` +
'Source-timestamped energy price data. Dataset access and freshness vary.\n\n' +
- 'Google Workspace Marketplace publication is pending.\n\n' +
+ 'Available in Google Workspace Marketplace. The listing runtime is managed separately during staged releases.\n\n' +
+ `Install: ${MARKETPLACE_URL}\n\n` +
'Website: https://www.oilpriceapi.com\n' +
'Docs: https://docs.oilpriceapi.com',
ui.ButtonSet.OK
@@ -215,9 +226,16 @@ function saveApiKey(apiKey) {
);
}
documentProperties.setProperty(KEY_PROPERTY, apiKey.trim());
+ const previousGeneration = Number(documentProperties.getProperty(CACHE_GENERATION_PROPERTY));
+ const cacheGeneration = String(
+ Math.max(Date.now(), Number.isFinite(previousGeneration) ? previousGeneration + 1 : 0)
+ );
+ documentProperties.setProperty(CACHE_GENERATION_PROPERTY, cacheGeneration);
const userProperties = PropertiesService.getUserProperties();
userProperties.setProperty(spreadsheetKeyProperty, apiKey.trim());
+ userProperties.setProperty(`${CACHE_GENERATION_PROPERTY}:${getActiveSpreadsheetId_()}`, cacheGeneration);
userProperties.deleteProperty(KEY_PROPERTY);
+ cachedGeneration_ = null;
return {
success: true,
message: 'API key saved for this spreadsheet in Apps Script properties.'
@@ -229,12 +247,16 @@ function deleteApiKey() {
if (documentProperties) {
documentProperties.deleteProperty(KEY_PROPERTY);
documentProperties.deleteProperty(LAST_DIAGNOSTIC_PROPERTY);
+ documentProperties.deleteProperty(CACHE_GENERATION_PROPERTY);
}
const userProperties = PropertiesService.getUserProperties();
const spreadsheetKeyProperty = getSpreadsheetKeyProperty_();
if (spreadsheetKeyProperty) userProperties.deleteProperty(spreadsheetKeyProperty);
+ const spreadsheetId = getActiveSpreadsheetId_();
+ if (spreadsheetId) userProperties.deleteProperty(`${CACHE_GENERATION_PROPERTY}:${spreadsheetId}`);
userProperties.deleteProperty(KEY_PROPERTY);
userProperties.deleteProperty(LAST_DIAGNOSTIC_PROPERTY);
+ cachedGeneration_ = null;
return {
success: true,
message: 'Stored spreadsheet API key and request diagnostic deleted.'
@@ -290,6 +312,147 @@ function responseHeader_(response, name) {
return key && typeof headers[key] === 'string' ? headers[key].slice(0, 128) : '';
}
+function cacheGeneration_() {
+ if (cachedGeneration_ !== null) return cachedGeneration_;
+ const documentProperties = getDocumentProperties_();
+ const documentGeneration = documentProperties
+ ? documentProperties.getProperty(CACHE_GENERATION_PROPERTY)
+ : null;
+ if (documentGeneration) {
+ cachedGeneration_ = documentGeneration;
+ return cachedGeneration_;
+ }
+
+ const spreadsheetId = getActiveSpreadsheetId_();
+ if (!spreadsheetId) {
+ cachedGeneration_ = 'legacy';
+ return cachedGeneration_;
+ }
+ cachedGeneration_ = PropertiesService.getUserProperties().getProperty(
+ `${CACHE_GENERATION_PROPERTY}:${spreadsheetId}`
+ ) || 'legacy';
+ return cachedGeneration_;
+}
+
+function stableCacheHash_(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}`;
+}
+
+function requestBlockKeys_(path) {
+ const endpoint = requestEndpoint_(path);
+ return {
+ global: 'request_block_global',
+ endpoint: `request_block_endpoint_${stableCacheHash_(endpoint)}`,
+ request: `request_block_request_${stableCacheHash_(String(path))}`
+ };
+}
+
+function cachedRequestBlock_(path) {
+ const keys = requestBlockKeys_(path);
+ for (const key of [keys.global, keys.endpoint, keys.request]) {
+ const blocked = getCachedValue_(key, MAX_CACHE_TTL_SECONDS, 'document');
+ if (blocked && typeof blocked.code === 'string' && typeof blocked.message === 'string') {
+ return blocked;
+ }
+ }
+ return null;
+}
+
+function clearRequestBlocks_(path) {
+ const keys = requestBlockKeys_(path);
+ for (const key of [keys.global, keys.endpoint, keys.request]) {
+ removeCachedValue_(key, 'document');
+ }
+}
+
+function retryWindowSeconds_(response, fallbackSeconds) {
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const reset = Number(responseHeader_(response, 'X-RateLimit-Reset'));
+ if (Number.isFinite(reset) && reset > nowSeconds) {
+ return Math.min(MAX_CACHE_TTL_SECONDS, Math.max(1, Math.ceil(reset - nowSeconds)));
+ }
+
+ const retryAfter = responseHeader_(response, 'Retry-After');
+ const numericRetry = Number(retryAfter);
+ if (Number.isFinite(numericRetry) && numericRetry > 0) {
+ const seconds = numericRetry > nowSeconds ? numericRetry - nowSeconds : numericRetry;
+ return Math.min(MAX_CACHE_TTL_SECONDS, Math.max(1, Math.ceil(seconds)));
+ }
+ const retryDate = new Date(retryAfter).getTime();
+ if (Number.isFinite(retryDate) && retryDate > Date.now()) {
+ return Math.min(
+ MAX_CACHE_TTL_SECONDS,
+ Math.max(1, Math.ceil((retryDate - Date.now()) / 1000))
+ );
+ }
+ return fallbackSeconds;
+}
+
+function responseMessage_(response) {
+ try {
+ const body = JSON.parse(response.getContentText());
+ const data = body && (body.data || body);
+ if (data && typeof data.message === 'string' && data.message.trim()) {
+ return data.message.trim().slice(0, 320);
+ }
+ } catch (error) {
+ // Use status-derived recovery text when the error body is unreadable.
+ }
+ return '';
+}
+
+function blockRequest_(path, statusCode, response, error) {
+ const keys = requestBlockKeys_(path);
+ let key = null;
+ let ttlSeconds = 0;
+ if (statusCode === 401) {
+ key = keys.global;
+ ttlSeconds = MAX_CACHE_TTL_SECONDS;
+ } else if (statusCode === 402) {
+ key = keys.global;
+ ttlSeconds = retryWindowSeconds_(response, MAX_CACHE_TTL_SECONDS);
+ } else if (statusCode === 429) {
+ key = keys.global;
+ ttlSeconds = retryWindowSeconds_(response, 60);
+ } else if (statusCode === 403) {
+ key = keys.endpoint;
+ ttlSeconds = MAX_CACHE_TTL_SECONDS;
+ } else if (statusCode === 404) {
+ key = keys.request;
+ ttlSeconds = 3600;
+ }
+ if (key && ttlSeconds > 0) {
+ putCachedValue_(
+ key,
+ { code: error.code, message: error.message },
+ ttlSeconds,
+ 'document'
+ );
+ }
+}
+
+function rememberResponseTier_(response) {
+ const tier = responseHeader_(response, 'X-RateLimit-Tier').trim().toLowerCase();
+ if (/^[a-z0-9_-]{1,32}$/.test(tier)) {
+ putCachedValue_('account_tier', tier, MAX_CACHE_TTL_SECONDS, 'document');
+ }
+}
+
+function latestCacheTtl_() {
+ const tier = getCachedValue_('account_tier', MAX_CACHE_TTL_SECONDS, 'document');
+ if (tier === 'free') return CACHE_TTL_SECONDS.latestFree;
+ if (tier === 'enterprise') return CACHE_TTL_SECONDS.latestEnterprise;
+ return CACHE_TTL_SECONDS.latest;
+}
+
function persistDiagnostic_(input) {
try {
const diagnostic = {
@@ -336,9 +499,14 @@ function getLastDiagnostic() {
}
}
-function requestJson_(path, apiKey) {
+function requestJson_(path, apiKey, options) {
const startedAt = Date.now();
const endpoint = requestEndpoint_(path);
+ const bypassBlock = options && options.bypassBlock === true;
+ if (!bypassBlock) {
+ const blocked = cachedRequestBlock_(path);
+ if (blocked) throw makeError_(blocked.code, blocked.message);
+ }
let response;
try {
const relativePath = String(path).startsWith('/v1/') ? String(path).slice(3) : String(path);
@@ -370,21 +538,40 @@ function requestJson_(path, apiKey) {
const statusCode = response.getResponseCode();
const requestId = responseHeader_(response, 'x-request-id');
+ rememberResponseTier_(response);
if (statusCode === 401) {
+ const apiError = makeError_('AUTH_INVALID', `Invalid or revoked API key. Replace it from ${SIGNUP_URL}.`);
persistDiagnostic_({ result: 'http-error', code: 'AUTH_INVALID', endpoint, durationMs: Date.now() - startedAt, httpStatus: statusCode, requestId });
- throw makeError_('AUTH_INVALID', `Invalid or revoked API key. Replace it from ${SIGNUP_URL}.`);
+ blockRequest_(path, statusCode, response, apiError);
+ throw apiError;
+ }
+ if (statusCode === 402) {
+ const detail = responseMessage_(response);
+ const apiError = makeError_(
+ 'UPGRADE_REQUIRED',
+ `${detail ? `${detail} ` : ''}Review or upgrade access at ${PRICING_URL}.`
+ );
+ persistDiagnostic_({ result: 'http-error', code: 'UPGRADE_REQUIRED', endpoint, durationMs: Date.now() - startedAt, httpStatus: statusCode, requestId });
+ blockRequest_(path, statusCode, response, apiError);
+ throw apiError;
}
- if (statusCode === 402 || statusCode === 403) {
+ if (statusCode === 403) {
+ const apiError = makeError_('UPGRADE_REQUIRED', `This account cannot access the requested dataset. Review ${PRICING_URL}.`);
persistDiagnostic_({ result: 'http-error', code: 'UPGRADE_REQUIRED', endpoint, durationMs: Date.now() - startedAt, httpStatus: statusCode, requestId });
- throw makeError_('UPGRADE_REQUIRED', `This account cannot access the requested dataset. Review ${PRICING_URL}.`);
+ blockRequest_(path, statusCode, response, apiError);
+ throw apiError;
}
if (statusCode === 429) {
+ const apiError = makeError_('RATE_LIMITED', 'OilPriceAPI rate or quota limit reached. Wait for the current limit window before retrying.');
persistDiagnostic_({ result: 'http-error', code: 'RATE_LIMITED', endpoint, durationMs: Date.now() - startedAt, httpStatus: statusCode, requestId });
- throw makeError_('RATE_LIMITED', 'OilPriceAPI rate or quota limit reached. Wait for the current limit window before retrying.');
+ blockRequest_(path, statusCode, response, apiError);
+ throw apiError;
}
if (statusCode === 404) {
+ const apiError = makeError_('NO_DATA', 'The requested OilPriceAPI resource was not found. Check the code and endpoint.');
persistDiagnostic_({ result: 'http-error', code: 'NO_DATA', endpoint, durationMs: Date.now() - startedAt, httpStatus: statusCode, requestId });
- throw makeError_('NO_DATA', 'The requested OilPriceAPI resource was not found. Check the code and endpoint.');
+ blockRequest_(path, statusCode, response, apiError);
+ throw apiError;
}
if (statusCode === 400 || statusCode === 422) {
let message = `OilPriceAPI rejected the request (HTTP ${statusCode}).`;
@@ -409,6 +596,8 @@ function requestJson_(path, apiKey) {
throw makeError_('ERROR', `OilPriceAPI request failed with HTTP ${statusCode}.`);
}
+ clearRequestBlocks_(path);
+
let body;
try {
body = JSON.parse(response.getContentText());
@@ -497,60 +686,270 @@ function validatePriceRecord_(record, subject) {
};
}
-function getCachedValue_(cacheKey, maxAgeSeconds) {
- const cache = CacheService.getUserCache();
- const raw = cache.get(cacheKey);
- if (!raw) return null;
+function cacheStore_(scope) {
+ if (typeof CacheService === 'undefined') return null;
+ if (scope === 'document' && typeof CacheService.getDocumentCache === 'function') {
+ try {
+ const documentCache = CacheService.getDocumentCache();
+ if (documentCache) return documentCache;
+ } catch (error) {
+ // Fall through to the per-user cache when the document cache is unavailable.
+ }
+ }
+ try {
+ return typeof CacheService.getUserCache === 'function'
+ ? CacheService.getUserCache()
+ : null;
+ } catch (error) {
+ return null;
+ }
+}
+
+function namespacedCacheKey_(cacheKey) {
+ return `opa_${cacheGeneration_()}_${cacheKey}`;
+}
- let envelope;
+function getCachedValue_(cacheKey, maxAgeSeconds, scope) {
try {
- envelope = JSON.parse(raw);
+ const cache = cacheStore_(scope || 'document');
+ if (!cache || typeof cache.get !== 'function') return null;
+ const namespacedKey = namespacedCacheKey_(cacheKey);
+ const raw = cache.get(namespacedKey);
+ if (!raw) return null;
+
+ let envelope;
+ try {
+ envelope = JSON.parse(raw);
+ } catch (error) {
+ try {
+ cache.remove(namespacedKey);
+ } catch (removeError) {
+ // An invalid entry can expire naturally if removal is unavailable.
+ }
+ return null;
+ }
+ if (
+ !envelope ||
+ typeof envelope.cachedAt !== 'number' ||
+ !Object.prototype.hasOwnProperty.call(envelope, 'value') ||
+ Date.now() - envelope.cachedAt > maxAgeSeconds * 1000
+ ) {
+ try {
+ cache.remove(namespacedKey);
+ } catch (removeError) {
+ // An expired entry can expire naturally if removal is unavailable.
+ }
+ return null;
+ }
+ return envelope.value;
} catch (error) {
- cache.remove(cacheKey);
+ // Cache failures must degrade to a live request.
return null;
}
+}
+
+function putCachedValue_(cacheKey, value, ttlSeconds, scope) {
+ try {
+ cacheStore_(scope || 'document').put(
+ namespacedCacheKey_(cacheKey),
+ JSON.stringify({ cachedAt: Date.now(), value }),
+ Math.min(MAX_CACHE_TTL_SECONDS, Math.max(1, Math.round(ttlSeconds)))
+ );
+ } catch (error) {
+ // Cache limits or transient cache failures must not replace live API data.
+ }
+}
+
+function removeCachedValue_(cacheKey, scope) {
+ try {
+ const cache = cacheStore_(scope || 'document');
+ if (cache && typeof cache.remove === 'function') {
+ cache.remove(namespacedCacheKey_(cacheKey));
+ }
+ } catch (error) {
+ // Request-block cleanup must not replace a valid live response.
+ }
+}
+
+function cacheMissLock_() {
if (
- !envelope ||
- typeof envelope.cachedAt !== 'number' ||
- !Object.prototype.hasOwnProperty.call(envelope, 'value') ||
- Date.now() - envelope.cachedAt > maxAgeSeconds * 1000
- ) {
- cache.remove(cacheKey);
+ typeof LockService === 'undefined' ||
+ typeof LockService.getDocumentLock !== 'function'
+ ) return null;
+ try {
+ return LockService.getDocumentLock();
+ } catch (error) {
return null;
}
- return envelope.value;
}
-function putCachedValue_(cacheKey, value, ttlSeconds) {
- CacheService.getUserCache().put(
- cacheKey,
- JSON.stringify({ cachedAt: Date.now(), value }),
- ttlSeconds
- );
+function withCacheMissLock_(cacheKey, maxAgeSeconds, loader) {
+ const lock = cacheMissLock_();
+ if (!lock) return loader();
+ let acquired;
+ try {
+ acquired = lock.tryLock(5000);
+ } catch (error) {
+ return loader();
+ }
+ if (!acquired) {
+ const afterWait = getCachedValue_(cacheKey, maxAgeSeconds, 'document');
+ if (afterWait !== null) return afterWait;
+ throw makeError_(
+ 'RETRY_LATER',
+ 'Another sheet calculation is refreshing this value. Recalculate shortly.'
+ );
+ }
+ try {
+ const afterLock = getCachedValue_(cacheKey, maxAgeSeconds, 'document');
+ return afterLock !== null ? afterLock : loader();
+ } finally {
+ try {
+ lock.releaseLock();
+ } catch (error) {
+ // Releasing a transient service handle must not replace live data.
+ }
+ }
+}
+
+function cachedRequestJson_(cacheKey, path, ttlSeconds) {
+ const cached = getCachedValue_(cacheKey, ttlSeconds, 'document');
+ if (cached !== null) return cached;
+ return withCacheMissLock_(cacheKey, ttlSeconds, () => {
+ const body = requestJson_(path, requireApiKey_());
+ putCachedValue_(cacheKey, body, ttlSeconds, 'document');
+ return body;
+ });
}
function getLatestRecord_(commodityCode) {
const code = normalizeCode_(commodityCode, 'Commodity code');
const cacheKey = `latest_${code}`;
- const cached = getCachedValue_(cacheKey, CACHE_TTL_SECONDS.latest);
+ const ttlSeconds = latestCacheTtl_();
+ const cached = getCachedValue_(cacheKey, ttlSeconds, 'document');
if (cached) return cached;
- const body = requestJson_(`/prices/latest?by_code=${encodeURIComponent(code)}`, requireApiKey_());
- let record;
+ return withCacheMissLock_(cacheKey, ttlSeconds, () => {
+ const body = requestJson_(`/prices/latest?by_code=${encodeURIComponent(code)}`, requireApiKey_());
+ let record;
+ try {
+ record = validatePriceRecord_(extractPriceRecords_(body, 'price')[0], 'Price record');
+ } catch (error) {
+ persistDiagnostic_({
+ result: 'invalid-response',
+ code: 'INVALID_RESPONSE',
+ endpoint: '/v1/prices/latest',
+ durationMs: 0,
+ httpStatus: 200
+ });
+ throw error;
+ }
+ putCachedValue_(cacheKey, record, latestCacheTtl_(), 'document');
+ return record;
+ });
+}
+
+function normalizeCodeRange_(values) {
+ const rows = Array.isArray(values) ? values : [values];
+ const flattened = [];
+ for (const row of rows) {
+ if (Array.isArray(row)) flattened.push(...row);
+ else flattened.push(row);
+ }
+ const codes = [];
+ for (const value of flattened) {
+ if (value === null || value === undefined || String(value).trim() === '') continue;
+ const code = normalizeCode_(value, 'Commodity code');
+ if (!codes.includes(code)) codes.push(code);
+ }
+ if (codes.length === 0) {
+ throw makeError_('INVALID_CODE', 'Select at least one commodity code.');
+ }
+ if (codes.length > MAX_BATCH_CODES) {
+ throw makeError_('INVALID_CODE', `Select at most ${MAX_BATCH_CODES} commodity codes.`);
+ }
+ return codes;
+}
+
+function readLatestRecordsFromCache_(codes, ttlSeconds) {
+ const records = new Map();
+ for (const code of codes) {
+ const record = getCachedValue_(`latest_${code}`, ttlSeconds, 'document');
+ if (record) records.set(code, record);
+ }
+ return records;
+}
+
+function getLatestRecords_(codes) {
+ let ttlSeconds = latestCacheTtl_();
+ let records = readLatestRecordsFromCache_(codes, ttlSeconds);
+ if (records.size === codes.length) return codes.map((code) => records.get(code));
+
+ let lock = cacheMissLock_();
+ let acquired = true;
+ if (lock) {
+ try {
+ acquired = lock.tryLock(5000);
+ } catch (error) {
+ lock = null;
+ }
+ }
+ if (!acquired) {
+ records = readLatestRecordsFromCache_(codes, latestCacheTtl_());
+ if (records.size === codes.length) return codes.map((code) => records.get(code));
+ throw makeError_(
+ 'RETRY_LATER',
+ 'Another sheet calculation is refreshing these values. Recalculate shortly.'
+ );
+ }
+
try {
- record = validatePriceRecord_(extractPriceRecords_(body, 'price')[0], 'Price record');
- } catch (error) {
- persistDiagnostic_({
- result: 'invalid-response',
- code: 'INVALID_RESPONSE',
- endpoint: '/v1/prices/latest',
- durationMs: 0,
- httpStatus: 200
- });
- throw error;
+ ttlSeconds = latestCacheTtl_();
+ records = readLatestRecordsFromCache_(codes, ttlSeconds);
+ const missingCodes = codes.filter((code) => !records.has(code));
+ if (missingCodes.length > 0) {
+ const body = requestJson_(
+ `/prices/latest?by_code=${encodeURIComponent(missingCodes.join(','))}`,
+ requireApiKey_()
+ );
+ const missingSet = new Set(missingCodes);
+ const recordErrors = new Map();
+ for (const rawRecord of extractPriceRecords_(body, 'prices')) {
+ let rawCode;
+ try {
+ rawCode = normalizeCode_(rawRecord && (rawRecord.code || rawRecord.symbol), 'Price record code');
+ } catch (error) {
+ continue;
+ }
+ if (!missingSet.has(rawCode)) continue;
+ let record;
+ try {
+ record = validatePriceRecord_(rawRecord, 'Price record');
+ } catch (error) {
+ recordErrors.set(rawCode, error);
+ continue;
+ }
+ if (!missingSet.has(record.code)) continue;
+ records.set(record.code, record);
+ putCachedValue_(`latest_${record.code}`, record, latestCacheTtl_(), 'document');
+ }
+ for (const code of missingCodes) {
+ if (!records.has(code) && !recordErrors.has(code)) {
+ recordErrors.set(code, makeError_('NO_DATA', `No latest value was returned for ${code}.`));
+ }
+ }
+ return codes.map((code) => records.get(code) || { code, error: recordErrors.get(code) });
+ }
+ return codes.map((code) => records.get(code));
+ } finally {
+ if (lock) {
+ try {
+ lock.releaseLock();
+ } catch (error) {
+ // Releasing a transient service handle must not replace live data.
+ }
+ }
}
- putCachedValue_(cacheKey, record, CACHE_TTL_SECONDS.latest);
- return record;
}
function testConnection() {
@@ -558,7 +957,11 @@ function testConnection() {
return { success: false, message: `Configure an API key first at ${SIGNUP_URL}.` };
}
try {
- const body = requestJson_('/prices/latest?by_code=BRENT_CRUDE_USD', getApiKey_());
+ const body = requestJson_(
+ '/prices/latest?by_code=BRENT_CRUDE_USD',
+ getApiKey_(),
+ { bypassBlock: true }
+ );
validatePriceRecord_(extractPriceRecords_(body, 'price')[0], 'Price record');
return { success: true, message: 'Connection and response schema verified.' };
} catch (error) {
@@ -867,7 +1270,38 @@ function appendTruncationNote_(path, payload, table) {
* @customfunction
*/
function OILPRICE(commodityCode) {
- return getLatestRecord_(commodityCode).price;
+ try {
+ return getLatestRecord_(commodityCode).price;
+ } catch (error) {
+ return formulaError_(error);
+ }
+}
+
+/**
+ * Fetches up to 25 commodity codes from a range in one request and spills a table.
+ * @param {Array} commodityCodes One-column range of OilPriceAPI commodity codes.
+ * @return {Array} Source-aware latest-price rows or a worksheet-readable error.
+ * @customfunction
+ */
+function OILPRICE_TABLE(commodityCodes) {
+ try {
+ const codes = normalizeCodeRange_(commodityCodes);
+ const records = getLatestRecords_(codes);
+ return [['Code', 'Price', 'Currency', 'Unit', 'Source', 'Source Timestamp']].concat(
+ records.map((record) => record.error
+ ? [record.code, formulaError_(record.error), '', '', '', '']
+ : [
+ record.code,
+ record.price,
+ record.currency,
+ record.unit,
+ record.source,
+ record.timestamp
+ ])
+ );
+ } catch (error) {
+ return formulaTableError_(error);
+ }
}
/**
@@ -897,9 +1331,14 @@ function OILPRICE_GET(path, query) {
try {
const normalizedPath = normalizeApiPath_(path);
const normalizedQuery = normalizeApiQuery_(query);
- const payload = requestJson_(
- `${normalizedPath}${normalizedQuery ? `?${normalizedQuery}` : ''}`,
- requireApiKey_()
+ const requestPath = `${normalizedPath}${normalizedQuery ? `?${normalizedQuery}` : ''}`;
+ const ttlSeconds = normalizedPath === '/v1/commodities'
+ ? CACHE_TTL_SECONDS.catalog
+ : CACHE_TTL_SECONDS.generic;
+ const payload = cachedRequestJson_(
+ `get_${stableCacheHash_(requestPath)}`,
+ requestPath,
+ ttlSeconds
);
return appendTruncationNote_(normalizedPath, payload, responseToTable_(payload));
} catch (error) {
@@ -985,35 +1424,45 @@ function OILPRICE_INFO(commodityCode) {
* @customfunction
*/
function OILPRICE_HISTORY(commodityCode, days) {
- const code = normalizeCode_(commodityCode, 'Commodity code');
- const requestedDays = days === undefined || days === null || days === '' ? 30 : Number(days);
- if (!Number.isInteger(requestedDays) || requestedDays < 1 || requestedDays > 365) {
- throw new Error('History days must be an integer from 1 through 365.');
+ try {
+ const code = normalizeCode_(commodityCode, 'Commodity code');
+ const requestedDays = days === undefined || days === null || days === '' ? 30 : Number(days);
+ if (!Number.isInteger(requestedDays) || requestedDays < 1 || requestedDays > 365) {
+ throw makeError_('INVALID_INPUT', 'History days must be an integer from 1 through 365.');
+ }
+ let endpoint = 'past_year';
+ if (requestedDays <= 1) endpoint = 'past_day';
+ else if (requestedDays <= 7) endpoint = 'past_week';
+ else if (requestedDays <= 30) endpoint = 'past_month';
+
+ const cacheKey = `history_${code}_${endpoint}`;
+ const cached = getCachedValue_(cacheKey, CACHE_TTL_SECONDS.history, 'document');
+ if (cached) return cached;
+
+ return withCacheMissLock_(cacheKey, CACHE_TTL_SECONDS.history, () => {
+ const body = requestJson_(
+ `/prices/${endpoint}?by_code=${encodeURIComponent(code)}`,
+ requireApiKey_()
+ );
+ const records = extractPriceRecords_(body, 'historical price');
+ const result = records.map((record) => {
+ const normalized = validatePriceRecord_(record, 'Historical price record');
+ return [normalized.timestamp, normalized.price];
+ });
+ putCachedValue_(cacheKey, result, CACHE_TTL_SECONDS.history, 'document');
+ return result;
+ });
+ } catch (error) {
+ return formulaTableError_(error);
}
- const cacheKey = `history_${code}_${requestedDays}`;
- const cached = getCachedValue_(cacheKey, CACHE_TTL_SECONDS.history);
- if (cached) return cached;
-
- let endpoint = 'past_year';
- if (requestedDays <= 1) endpoint = 'past_day';
- else if (requestedDays <= 7) endpoint = 'past_week';
- else if (requestedDays <= 30) endpoint = 'past_month';
-
- const body = requestJson_(
- `/prices/${endpoint}?by_code=${encodeURIComponent(code)}`,
- requireApiKey_()
- );
- const records = extractPriceRecords_(body, 'historical price');
- const result = records.map((record) => {
- const normalized = validatePriceRecord_(record, 'Historical price record');
- return [normalized.timestamp, normalized.price];
- });
- putCachedValue_(cacheKey, result, CACHE_TTL_SECONDS.history);
- return result;
}
function fetchExchangeRates() {
- const body = requestJson_('/prices/latest?by_code=GBP_USD,EUR_USD', requireApiKey_());
+ const body = cachedRequestJson_(
+ 'exchange_rates_gbp_eur_usd',
+ '/prices/latest?by_code=GBP_USD,EUR_USD',
+ CACHE_TTL_SECONDS.exchangeRates
+ );
const records = extractPriceRecords_(body, 'exchange rates').map((record) => validatePriceRecord_(record, 'Exchange-rate record'));
const gbp = records.find((record) => record.code === 'GBP_USD');
const eur = records.find((record) => record.code === 'EUR_USD');
@@ -1049,14 +1498,18 @@ function heatContent_(commodityInfo) {
* @customfunction
*/
function OILPRICE_CONVERT(commodityCode) {
- const code = normalizeCode_(commodityCode, 'Commodity code');
- const commodityInfo = COMMODITY_MAP[code];
- if (!commodityInfo) {
- throw new Error('This code has no reference heat-content conversion mapping.');
+ try {
+ const code = normalizeCode_(commodityCode, 'Commodity code');
+ const commodityInfo = COMMODITY_MAP[code];
+ if (!commodityInfo) {
+ throw makeError_('INVALID_CODE', 'This code has no reference heat-content conversion mapping.');
+ }
+ const record = getLatestRecord_(code);
+ const rates = record.currency === 'USD' ? null : fetchExchangeRates();
+ return toUsd_(record.price, record.currency, rates) / heatContent_(commodityInfo);
+ } catch (error) {
+ return formulaError_(error);
}
- const record = getLatestRecord_(code);
- const rates = record.currency === 'USD' ? null : fetchExchangeRates();
- return toUsd_(record.price, record.currency, rates) / heatContent_(commodityInfo);
}
function convertToMBtu() {
@@ -1134,7 +1587,12 @@ function validateBunkerRecord_(record) {
}
function fetchBunkerRecords_(query) {
- const body = requestJson_(`/prices/data-connector${query || ''}`, requireApiKey_());
+ const path = `/prices/data-connector${query || ''}`;
+ const body = cachedRequestJson_(
+ `bunker_${stableCacheHash_(path)}`,
+ path,
+ CACHE_TTL_SECONDS.bunker
+ );
return extractPriceRecords_(body, 'bunker price').map(validateBunkerRecord_);
}
@@ -1176,21 +1634,29 @@ function writeToDataConnectorSheet(prices) {
/** @customfunction */
function BUNKER_PRICE(port, fuelType) {
- const normalizedPort = normalizeCode_(port, 'Port');
- const normalizedFuel = normalizeCode_(fuelType, 'Fuel type');
- const records = fetchBunkerRecords_(
- `?port=${encodeURIComponent(normalizedPort)}&fuel_type=${encodeURIComponent(normalizedFuel)}`
- );
- return records[0].price;
+ try {
+ const normalizedPort = normalizeCode_(port, 'Port');
+ const normalizedFuel = normalizeCode_(fuelType, 'Fuel type');
+ const records = fetchBunkerRecords_(
+ `?port=${encodeURIComponent(normalizedPort)}&fuel_type=${encodeURIComponent(normalizedFuel)}`
+ );
+ return records[0].price;
+ } catch (error) {
+ return formulaError_(error);
+ }
}
/** @customfunction */
function BUNKER_PORT_PRICES(port) {
- const normalizedPort = normalizeCode_(port, 'Port');
- const records = fetchBunkerRecords_(`?port=${encodeURIComponent(normalizedPort)}`);
- return [['Fuel Type', 'Price', 'Currency', 'Unit', 'Source Timestamp']].concat(
- records.map((record) => [record.fuelType, record.price, record.currency, record.unit, record.timestamp])
- );
+ try {
+ const normalizedPort = normalizeCode_(port, 'Port');
+ const records = fetchBunkerRecords_(`?port=${encodeURIComponent(normalizedPort)}`);
+ return [['Fuel Type', 'Price', 'Currency', 'Unit', 'Source Timestamp']].concat(
+ records.map((record) => [record.fuelType, record.price, record.currency, record.unit, record.timestamp])
+ );
+ } catch (error) {
+ return formulaTableError_(error);
+ }
}
function validateFutureContract_(record, subject) {
@@ -1205,29 +1671,41 @@ function validateFutureContract_(record, subject) {
/** @customfunction */
function FUTURES_PRICE(contract) {
- const code = normalizeCode_(contract, 'Contract code');
- const cacheKey = `futures_price_${code}`;
- const cached = getCachedValue_(cacheKey, CACHE_TTL_SECONDS.futures);
- if (cached !== null) return cached;
- const body = requestJson_(`/futures/latest?contract=${encodeURIComponent(code)}`, requireApiKey_());
- const value = validateFutureContract_(extractDataArray_(body, 'contracts', 'futures contracts')[0], 'Futures contract').price;
- putCachedValue_(cacheKey, value, CACHE_TTL_SECONDS.futures);
- return value;
+ try {
+ const code = normalizeCode_(contract, 'Contract code');
+ const cacheKey = `futures_price_${code}`;
+ const cached = getCachedValue_(cacheKey, CACHE_TTL_SECONDS.futures, 'document');
+ if (cached !== null) return cached;
+ return withCacheMissLock_(cacheKey, CACHE_TTL_SECONDS.futures, () => {
+ const body = requestJson_(`/futures/latest?contract=${encodeURIComponent(code)}`, requireApiKey_());
+ const value = validateFutureContract_(extractDataArray_(body, 'contracts', 'futures contracts')[0], 'Futures contract').price;
+ putCachedValue_(cacheKey, value, CACHE_TTL_SECONDS.futures, 'document');
+ return value;
+ });
+ } catch (error) {
+ return formulaError_(error);
+ }
}
/** @customfunction */
function FUTURES_CURVE(contract) {
- const code = normalizeCode_(contract, 'Contract code');
- const cacheKey = `futures_curve_${code}`;
- const cached = getCachedValue_(cacheKey, CACHE_TTL_SECONDS.futures);
- if (cached) return cached;
- const body = requestJson_(`/futures/curve?contract=${encodeURIComponent(code)}`, requireApiKey_());
- const contracts = extractDataArray_(body, 'contracts', 'futures contracts').map((record) => validateFutureContract_(record, 'Futures contract'));
- const result = [['Month', 'Price', 'Change']].concat(
- contracts.map((record) => [record.month, record.price, record.change])
- );
- putCachedValue_(cacheKey, result, CACHE_TTL_SECONDS.futures);
- return result;
+ try {
+ const code = normalizeCode_(contract, 'Contract code');
+ const cacheKey = `futures_curve_${code}`;
+ const cached = getCachedValue_(cacheKey, CACHE_TTL_SECONDS.futures, 'document');
+ if (cached) return cached;
+ return withCacheMissLock_(cacheKey, CACHE_TTL_SECONDS.futures, () => {
+ const body = requestJson_(`/futures/curve?contract=${encodeURIComponent(code)}`, requireApiKey_());
+ const contracts = extractDataArray_(body, 'contracts', 'futures contracts').map((record) => validateFutureContract_(record, 'Futures contract'));
+ const result = [['Month', 'Price', 'Change']].concat(
+ contracts.map((record) => [record.month, record.price, record.change])
+ );
+ putCachedValue_(cacheKey, result, CACHE_TTL_SECONDS.futures, 'document');
+ return result;
+ });
+ } catch (error) {
+ return formulaTableError_(error);
+ }
}
function showFuturesInfo() {
@@ -1255,27 +1733,36 @@ function validateRigData_(data) {
/** @customfunction */
function RIG_COUNT(type) {
- const selectedType = String(type || 'total').toLowerCase();
- const cacheKey = 'rig_count_data';
- let rigData = getCachedValue_(cacheKey, CACHE_TTL_SECONDS.rigCount);
- if (!rigData) {
- const body = requestJson_('/rig-counts/latest', requireApiKey_());
- rigData = validateRigData_(body.data);
- putCachedValue_(cacheKey, rigData, CACHE_TTL_SECONDS.rigCount);
- }
- if (selectedType === 'oil') return rigData.oil;
- if (selectedType === 'gas') return rigData.gas;
- if (selectedType === 'total') return rigData.total;
- if (selectedType === 'all') {
- return [
- ['Type', 'Count'],
- ['Oil', rigData.oil],
- ['Gas', rigData.gas],
- ['Total', rigData.total],
- ['Source Date', rigData.date]
- ];
+ try {
+ const selectedType = String(type || 'total').toLowerCase();
+ const cacheKey = 'rig_count_data';
+ let rigData = getCachedValue_(cacheKey, CACHE_TTL_SECONDS.rigCount, 'document');
+ if (!rigData) {
+ rigData = withCacheMissLock_(cacheKey, CACHE_TTL_SECONDS.rigCount, () => {
+ const body = requestJson_('/rig-counts/latest', requireApiKey_());
+ const validated = validateRigData_(body.data);
+ putCachedValue_(cacheKey, validated, CACHE_TTL_SECONDS.rigCount, 'document');
+ return validated;
+ });
+ }
+ if (selectedType === 'oil') return rigData.oil;
+ if (selectedType === 'gas') return rigData.gas;
+ if (selectedType === 'total') return rigData.total;
+ if (selectedType === 'all') {
+ return [
+ ['Type', 'Count'],
+ ['Oil', rigData.oil],
+ ['Gas', rigData.gas],
+ ['Total', rigData.total],
+ ['Source Date', rigData.date]
+ ];
+ }
+ throw makeError_('INVALID_INPUT', 'Rig count type must be oil, gas, total, or all.');
+ } catch (error) {
+ return String(type || 'total').toLowerCase() === 'all'
+ ? formulaTableError_(error)
+ : formulaError_(error);
}
- throw new Error('Rig count type must be oil, gas, total, or all.');
}
function showRigCountInfo() {
diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md
index a5f9e97..0730edb 100644
--- a/DEPLOYMENT_GUIDE.md
+++ b/DEPLOYMENT_GUIDE.md
@@ -6,7 +6,8 @@ test installation, screenshots, and submission require the publisher account.
## Current release gate
-- Runtime version: `1.2.2`
+- Public runtime version: `1.2.2`
+- Repository release candidate: `1.3.0`
- Production Apps Script ID:
`1rlVWvciYu-wzqnY009I3oW-08ZPazYK1snrrMg9NNY7c5WBSkUK8W2Hb`
- Current immutable Apps Script version: `11` (runtime `1.2.2`, cut
@@ -17,21 +18,19 @@ test installation, screenshots, and submission require the publisher account.
`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.
-- Marketplace status: **version 9 draft submitted for review**. Version 9 was
- cut 2026-07-28 16:53 EDT and therefore predates BOTH PR #19 (OAuth
- verification prep) and PR #22 (custom-function credential fix). **Version 11
- is the release candidate — repin App Configuration from 9 to 11 before
- publishing.** Nothing has ever been published; there is no live listing.
+- 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
+ an installed Marketplace formula smoke.
- Runtime push/version and local deployment checks: complete
- Marketplace review receipt and a real 1280x800 screenshot: complete
- Public homepage, privacy policy, and terms deployment: complete
-- Separate OAuth verification dependency: confirm Search Console ownership,
- record the end-to-end authorization demo, submit branding and data-access
- verification, and preserve the confirmation receipt
-- Canonical remaining-work issue:
- `https://github.com/OilpriceAPI/google-sheets-addin/issues/20`
+- OAuth verification was submitted July 30, 2026 with reviewer video
+ `https://youtu.be/FakNSmBddhE`.
-Do not claim Marketplace availability until Google publishes the listing.
+Do not claim that a new runtime is public until App Configuration selects its
+smoke-proven immutable version.
## 1. Choose the publisher and Cloud project
@@ -192,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.2.2"
+npm run deploy:version -- "OilPriceAPI for Google Sheets 1.3.0 customer-path recovery"
npm run deploy:list
```
@@ -250,21 +249,22 @@ Verify locally before upload:
npm run verify:assets
```
-Save the listing as a draft while OAuth verification or screenshot review is
-pending.
+For a copy update, save a draft and preserve the existing public version until
+the replacement is approved and smoke-tested.
## 11. Submit and post-publication smoke
-1. Submit OAuth verification if required.
-2. Submit the public Marketplace listing for review.
-3. Track the Marketplace SDK publication status and review email sent to
- `support@oilpriceapi.com`.
-4. After approval, install the public listing using a separate clean account.
-5. Repeat the customer-critical smoke against the published version.
+1. Push the exact reviewed release and create an immutable Apps Script version.
+2. Install that version with a non-customer test account before changing the
+ public Marketplace configuration.
+3. Run key save/test, `OILPRICE_PRICE`, `OILPRICE_TABLE`, quota recovery, and
+ key deletion checks.
+4. Select the proven version in Marketplace App Configuration.
+5. Repeat the customer-critical smoke through the public listing.
6. Review Apps Script execution logs for new errors, retries, unexpected
authorization failures, and noisy request patterns.
-7. Only then update public marketing copy to say the add-on is available from
- the Google Workspace Marketplace and add the real listing URL.
+7. Record the exact version, account type, formulas, timestamps, and redacted
+ screenshots in the release issue.
## Updating an approved release
diff --git a/MARKETPLACE_LISTING.md b/MARKETPLACE_LISTING.md
index d376992..7799f32 100644
--- a/MARKETPLACE_LISTING.md
+++ b/MARKETPLACE_LISTING.md
@@ -1,19 +1,12 @@
# Google Workspace Marketplace Listing
-Status: the Marketplace Store Listing draft was resubmitted July 29, 2026 and
-remains in Google review. That draft references Apps Script version 9, which
-predates both PR #19 (OAuth verification prep) and PR #22 (custom-function
-credential fix).
+Status: **publicly available** at
+`https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434`.
-**Version 11 (runtime `1.2.2`) is the release candidate** and should replace
-version 9 in App Configuration before publishing. App Configuration is
-editable during review — only the Store Listing tab locks (verified
-2026-07-31; see `OAUTH_VERIFICATION.md`), so this repin does not have to wait
-for Google.
-
-OAuth branding and data-access verification have not yet been submitted.
-Do not claim Marketplace availability until Google approves and publishes the
-listing.
+Public Marketplace Apps Script version: `11` (runtime `1.2.2`). Runtime
+`1.3.0` remains a release candidate until an immutable Apps Script version is
+cut, smoke-tested through an installed Marketplace add-on, and selected in App
+Configuration.
## App details
@@ -35,7 +28,7 @@ Detailed description:
> request the latest available value, its currency and unit, source timestamp,
> freshness state, or an allowlisted API table.
>
-> Core formulas include OILPRICE_PRICE, OILPRICE_INFO, OILPRICE_STATUS,
+> Core formulas include OILPRICE_PRICE, OILPRICE_TABLE, OILPRICE_INFO, OILPRICE_STATUS,
> OILPRICE_UNIT, OILPRICE_CODES, and OILPRICE_GET. Existing OILPRICE,
> OILPRICE_HISTORY, futures, bunker-price, rig-count, and reference conversion
> formulas remain available.
@@ -80,41 +73,38 @@ The submitted OAuth/Marketplace configuration also displays Google's mandatory
`userinfo.email` and `userinfo.profile` defaults. The add-on does not use those
identity defaults for product behavior and does not request Drive-wide access.
-## Submission receipt
+## Current publication receipt
- Google Cloud project: `oilpriceapi-sheets-addon` (`991152473434`)
-- Marketplace draft Apps Script version: `9` (stale - repin to `11`)
+- Public listing:
+ `https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434`
+- Public Marketplace Apps Script version: `11`
- Latest immutable Apps Script version: `11`
- Runtime release represented by version 11: `1.2.2`
- Superseded: version 10 (`1.2.1`), cut before the PR #22 credential fix
- Integration: Google Sheets Editor add-on
- Install modes: individual and administrator
- Regions: all regions
-- Review state: **In review — resubmitted July 29, 2026**
+- Review state: **Published — independently verified August 10, 2026**
- Rejection email received: **July 27, 2026**
-- Google Cloud receipt: **“The draft is in review and can't be edited.”**
+- Historical Google Cloud receipt: **“The draft is in review and can't be edited.”**
- Verification-page production deployment:
`https://github.com/OilpriceAPI/website-clean/actions/runs/30434284989`
-- OAuth submission state: **not submitted**
-- Canonical remaining-work issue:
- `https://github.com/OilpriceAPI/google-sheets-addin/issues/20`
+- OAuth verification was submitted July 30, 2026 with the reviewer-accessible
+ demonstration `https://youtu.be/FakNSmBddhE`; the later public listing is the
+ customer-visible approval evidence.
-The OAuth consent screen is **In production**. The manifest and prepared
-submission use the three functional scopes above; the locked Marketplace draft
-uses the same scopes. Google's default `userinfo.email` and
+The OAuth consent screen is **In production**. The manifest and published
+submission use the three functional scopes above. Google's default `userinfo.email` and
`userinfo.profile` scopes remain in place. The public homepage, privacy policy,
and terms were corrected and deployed from website PR 1461. Cache-busted
production checks returned HTTP 200 from the canonical domain and found the
expected disclosure text on all three pages.
-Google Auth Platform still reports branding and data access as unverified.
-Before submission, an owner/editor of Cloud project `991152473434` must confirm
-Search Console ownership for `oilpriceapi.com`, record the required continuous
-OAuth demonstration, enter version 11 in App Configuration (already editable -
-it does not lock during review), submit branding and data-access
-verification, and preserve the
-resulting receipt. Track those actions only in issue 20 rather than opening
-parallel submission issues.
+Do not infer any private Cloud-console field beyond the receipts above. The
+public listing and installed-add-on smoke are the release gates for customer
+availability; future runtime revisions still require their own immutable
+version and installed formula smoke.
## Graphic assets
diff --git a/OAUTH_VERIFICATION.md b/OAUTH_VERIFICATION.md
index 3572251..29414db 100644
--- a/OAUTH_VERIFICATION.md
+++ b/OAUTH_VERIFICATION.md
@@ -4,77 +4,34 @@ This packet is for the production Google Cloud project
`oilpriceapi-sheets-addon` (`991152473434`) and the original
`OilPriceAPI for Google Sheets™` add-on.
-## Status as of July 31, 2026
-
-Release evidence below is from July 29, 2026. The "Current Google state"
-section was re-verified against the live Cloud console on July 31, 2026.
-
-Completed release evidence:
-
-- Add-on PR 19 merged as
- `799f49f46059340ed332431f5c7ac87f5c91a695`.
-- All 49 runtime, recovery, disclosure, deployment-package, asset, portfolio,
- and secret-scan checks passed.
-- The exact merged runtime was pushed to the production Apps Script project.
-- Immutable Apps Script version 10 was created with description
- `OilPriceAPI for Google Sheets 1.2.1 OAuth verification`.
-- A fresh clone of version 10 matched the four-file reviewed release package
- exactly.
-- **Superseded by version 11.** PR #22 (custom-function credential fix) merged
- 2026-07-29 22:01 UTC, after version 10 was cut, and changed `Code.gs` and
- `Sidebar.html`. Immutable version 11 was cut the same minute (18:01 EDT) and
- carries runtime `1.2.2`; its `Code.gs` reads `ADDON_VERSION = '1.2.2'`
- (verified 2026-07-31). Version 11 is the release candidate.
-- Website PR 1461 merged as
- `c3acb510680992538315781fb0ce3dcec335bf20`.
-- Production deployment
- `https://github.com/OilpriceAPI/website-clean/actions/runs/30434284989`
- completed successfully, including production and money-page smoke checks
- and a Cloudflare purge.
-- Cache-busted checks returned HTTP 200 without a cross-domain redirect for
- the homepage, privacy policy, and terms. The responses contained the
- expected scope, Limited Use, and current-formula disclosures.
-- DigitalOcean deployment `65aaf4e7-df6a-44aa-a89b-de796963e442` is ACTIVE.
- Its first 500 runtime log lines contained no matched errors, warnings,
- retries, timeouts, or 5xx responses.
-
-Current Google state:
-
-- The Marketplace **Store Listing** draft is in review. That tab reports
- "The draft is in review and can't be edited" and exposes a "Cancel review"
- control.
-- The Marketplace **App Configuration** tab is _editable_ during that review.
- Verified 2026-07-31 by DOM inspection of the Cloud console: every input
- reports `disabled: false`, `readOnly: false`, with no `aria-disabled`. The
- Version field is a free-text ``, not a dropdown, and
- currently holds `9`. "Save Draft" is greyed only for want of unsaved
- changes.
- **Correction:** earlier revisions of this document asserted the App
- Configuration was locked during review. That is wrong, and it nearly drove
- an unnecessary cancel-and-recut. The accurate rule is: **Store Listing locks
- during review; App Configuration does not.**
-- Apps Script **version 11** (runtime `1.2.2`) is the current release
- candidate and is not yet selected in App Configuration, which still points
- at version 9.
-- OAuth publishing status is **In production**.
-- OAuth branding is **not verified**.
-- OAuth data access is **not verified**.
-- OAuth verification has **not been submitted**.
-- No public OAuth demonstration URL or Google submission receipt exists yet.
-
-Remaining owner-session work:
-
-1. Confirm that a Cloud project owner/editor is a verified Search Console owner
- for `oilpriceapi.com`.
-2. Record and publish the continuous end-to-end OAuth demonstration below.
-3. Update Marketplace App Configuration to Apps Script version **11**. This
- does not have to wait for Google - App Configuration is editable while the
- Store Listing is in review.
-4. Submit OAuth branding and data-access verification with the exact scopes,
- justifications, and public video URL.
-5. Capture the confirmation text, date, case/reference ID if present, and
- redacted screenshots in issue 20:
- `https://github.com/OilpriceAPI/google-sheets-addin/issues/20`.
+## Status as of August 11, 2026
+
+Customer-visible Google evidence:
+
+- The listing is publicly available at
+ `https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434`.
+- The public Marketplace configuration points to immutable Apps Script version
+ **11**, runtime `1.2.2`.
+- OAuth verification was submitted on July 30, 2026 for Cloud project
+ `991152473434`. The reviewer-accessible continuous demonstration is
+ `https://youtu.be/FakNSmBddhE`.
+- A Marketplace draft install previously proved custom-function registration,
+ spreadsheet-scoped key save, connection/schema validation,
+ `OILPRICE_PRICE("WTI_USD")`, `OILPRICE_CODES()`, and sidebar batch fetch.
+- Public availability was independently rechecked unauthenticated on August
+ 10, 2026; the canonical URL returned the OilPriceAPI listing while a bogus
+ application ID returned Google error 400.
+
+Historical release evidence remains relevant: PR #22 supplied the
+custom-function credential-context fix, immutable version 11 was cut after it,
+and the three functional scopes below were used for submission. Earlier draft
+versions 9 and 10 are superseded.
+
+Private Cloud-console fields are not inferred from public availability. The
+customer release gate is the public listing plus an installed-add-on formula
+smoke. Runtime `1.3.0` must therefore remain a release candidate until it is
+merged, pushed, cut as a new immutable Apps Script version, installed through
+Marketplace, and smoke-tested before App Configuration is updated.
## Branding values
@@ -142,21 +99,14 @@ and a non-customer test spreadsheet and OilPriceAPI key.
The recording must not expose an API key, Google account identifier, customer
data, browser password manager, clipboard contents, or unrelated tabs.
-## Submission order
-
-1. Deploy the public homepage, privacy policy, and terms above.
-2. Confirm all three URLs return `200` without authentication or redirects to
- another domain.
-3. Confirm Search Console ownership for `oilpriceapi.com`.
-4. Push and smoke the exact reviewed Apps Script source.
-5. Create a new immutable Apps Script version and enter that version in the
- Marketplace SDK.
-6. Record and upload the demo video with link visibility enabled for the
- Google review team.
-7. In Google Auth Platform, verify branding first, then submit Data Access
- verification with the scope justifications and demo link.
-8. Keep the Workspace Marketplace listing in review only after the OAuth
- verification request is accepted for review.
-
-Do not claim that the add-on is publicly installable until Google approves and
-publishes the Marketplace listing.
+## Future release order
+
+1. Validate and merge the exact reviewed source.
+2. Push it to the production Apps Script project and create a new immutable
+ version without changing the public Marketplace configuration.
+3. Install and smoke that candidate with a non-customer account.
+4. Select the candidate in Marketplace App Configuration only after key,
+ formula, batching, quota-recovery, and key-deletion checks pass.
+5. Repeat the smoke through the public listing and review Apps Script logs.
+
+Do not claim that a new runtime is public before its selected-version smoke.
diff --git a/README.md b/README.md
index 932a575..30c156c 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,10 @@
Deployment-ready Editor add-on for source-aware OilPriceAPI formulas in Google
Sheets™.
-> Google Workspace Marketplace publication is pending. The public listing was
-> submitted on July 26, 2026, rejected on July 27 pending trademark attribution
-> and OAuth verification remediation, and resubmitted on July 29 after the
-> listing, homepage, scopes, and Apps Script version were reconciled. Google
-> Cloud currently reports that the draft is in review. Do not claim Marketplace
-> availability until Google approves and publishes the listing.
+The add-on is [publicly available in Google Workspace Marketplace](https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434).
+The public listing currently points to immutable Apps Script version 11
+(`1.2.2`). Runtime `1.3.0` is a release candidate until its installed-add-on
+smoke and Marketplace version update are recorded.
Dataset access, history, freshness, and limits depend on the API key, source,
and account entitlement. Review the
@@ -43,11 +41,12 @@ The original `OILPRICE(code)` formula remains supported for existing sheets.
| Function | Behavior | Cache |
| --- | --- | --- |
-| `OILPRICE(code)` | Backward-compatible numeric latest price | 5 minutes |
+| `OILPRICE(code)` | Backward-compatible numeric latest price | Tier-aware shared cache |
+| `OILPRICE_TABLE(range)` | Up to 25 latest prices in one spilled request | Tier-aware shared cache |
| `OILPRICE_HISTORY(code, days)` | Source timestamp and price rows | 1 hour |
| `OILPRICE_CONVERT(code)` | Reference USD/MMBtu conversion for documented mappings | Latest-price cache |
-| `BUNKER_PRICE(port, fuel)` | Numeric Data Connector bunker price | None |
-| `BUNKER_PORT_PRICES(port)` | Bunker-price table with units and timestamp | None |
+| `BUNKER_PRICE(port, fuel)` | Numeric Data Connector bunker price | 5 minutes |
+| `BUNKER_PORT_PRICES(port)` | Bunker-price table with units and timestamp | 5 minutes |
| `FUTURES_PRICE(contract)` | Numeric first-contract price | 5 minutes |
| `FUTURES_CURVE(contract)` | Month, price, and change rows | 5 minutes |
| `RIG_COUNT(type)` | Oil, gas, total, or source-dated table | 1 hour |
@@ -67,7 +66,11 @@ The original `OILPRICE(code)` formula remains supported for existing sheets.
the Excel preview.
- Credential-shaped query keys are rejected before any network request.
- Missing, invalid, locked, rate-limited, timed-out, malformed, and empty
- responses fail with worksheet-readable recovery text.
+ responses fail with worksheet-readable recovery text. Terminal failures are
+ negatively cached and a connection check bypasses the cache so a paid upgrade
+ recovers immediately.
+- Latest values use a document cache and a lock-protected miss path. Free,
+ paid, and enterprise cache lifetimes follow the API's canonical tier header.
- Latest-request diagnostics contain endpoint path, status, duration,
timestamp, and optional request ID—never the API key or query string.
- The manifest requests only current-sheet, external-request, and container-UI
@@ -119,7 +122,7 @@ npm run clasp:login
read -r "OPA_SCRIPT_ID?Apps Script ID: "
npm run clasp:configure -- "$OPA_SCRIPT_ID"
npm run deploy:push
-npm run deploy:version -- "OilPriceAPI for Google Sheets 1.2.2 formula credential context fix"
+npm run deploy:version -- "OilPriceAPI for Google Sheets 1.3.0 customer-path recovery"
```
Editor add-on publication uses the Apps Script **script ID and version
@@ -132,6 +135,7 @@ generated assets are in [MARKETPLACE_LISTING.md](MARKETPLACE_LISTING.md).
## Canonical links
- [Product facts](https://api.oilpriceapi.com/product-facts.json)
+- [Workspace Marketplace listing](https://workspace.google.com/marketplace/app/oilpriceapi_for_google_sheets/991152473434)
- [API documentation](https://docs.oilpriceapi.com)
- [Pricing and dataset access](https://www.oilpriceapi.com/pricing)
- [Data usage](https://www.oilpriceapi.com/legal/data-usage)
diff --git a/Sidebar.html b/Sidebar.html
index ee8194c..16d5df9 100644
--- a/Sidebar.html
+++ b/Sidebar.html
@@ -167,7 +167,7 @@
OilPriceAPI
Source-aware formulas for Google Sheets™
This add-on works only in the spreadsheet where you open it. It sends only your API key and requested market identifiers to OilPriceAPI, plus reviewed filters needed for the selected endpoint. It does not request broad Google Drive access or use your Google email or profile.
- Marketplace publication is pending.
+ Available in Google Workspace Marketplace.
diff --git a/docs/index.html b/docs/index.html
index 47f6094..2ae8e05 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -6,7 +6,7 @@
OilPriceAPI for Google Sheets™
-
MARKETPLACE REJECTED — REMEDIATION IN PROGRESS
+
AVAILABLE IN GOOGLE WORKSPACE MARKETPLACE
OilPriceAPI for Google Sheets™
Source-aware energy data formulas with price, unit, timestamp,
@@ -80,10 +80,11 @@
OilPriceAPI for Google Sheets™
Publication status
- Google Workspace Marketplace publication is pending. The Editor add-on
- was submitted on July 26, 2026 and rejected on July 27 pending
- trademark attribution and OAuth verification remediation. Do not claim
- public availability until Google approves and publishes the listing.
+ Available in Google Workspace Marketplace.
+ The public listing points to immutable Apps Script version 11 (runtime
+ 1.2.2). Runtime 1.3.0 remains a release candidate until its replacement
+ version passes installed-add-on smoke testing and is selected in
+ Marketplace App Configuration.