diff --git a/src/pg/pg-store.ts b/src/pg/pg-store.ts index 73055c3..954c4c0 100644 --- a/src/pg/pg-store.ts +++ b/src/pg/pg-store.ts @@ -38,7 +38,7 @@ import { } from '@stacks/api-toolkit'; import * as path from 'path'; import { fileURLToPath } from 'url'; -import { StacksCorePgStore } from './stacks-core-pg-store.js'; +import { MAX_TOKEN_TTL_SECONDS, StacksCorePgStore } from './stacks-core-pg-store.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -217,11 +217,17 @@ export class PgStore extends BasePgStore { -- A job that is not done means a refresh is already in flight (e.g. a notification just -- arrived), and we keep serving the previous metadata until it completes. Advertising -- freshness then would cache metadata we already know is about to change. + -- Seconds left until the token may change, capped by the configured max. The TTL itself + -- is only clamped to keep an absurd value from overflowing the interval type, so a TTL + -- longer than the cap keeps advertising the cap instead of expiring along with it. CASE WHEN n.update_mode = 'dynamic' AND n.ttl IS NOT NULL AND j.status = 'done' THEN - CEIL(EXTRACT(EPOCH FROM ( - COALESCE(t.updated_at, t.created_at) - + INTERVAL '1 seconds' * LEAST(n.ttl, ${maxAge}) - NOW() - )))::int + LEAST( + CEIL(EXTRACT(EPOCH FROM ( + COALESCE(t.updated_at, t.created_at) + + INTERVAL '1 seconds' * LEAST(n.ttl, ${MAX_TOKEN_TTL_SECONDS}) - NOW() + ))), + ${maxAge} + )::int END AS max_age FROM tokens AS t INNER JOIN smart_contracts AS s ON s.id = t.smart_contract_id @@ -242,7 +248,7 @@ export class PgStore extends BasePgStore { const cache: DbTokenCacheInfo = { etag: result[0].etag }; // Only advertise a freshness lifetime if the token isn't already due for a refresh. if (result[0].max_age !== null && result[0].max_age > 0) { - cache.maxAge = Math.min(result[0].max_age, maxAge); + cache.maxAge = result[0].max_age; } return cache; } diff --git a/src/pg/stacks-core-pg-store.ts b/src/pg/stacks-core-pg-store.ts index 65a5162..0ac476b 100644 --- a/src/pg/stacks-core-pg-store.ts +++ b/src/pg/stacks-core-pg-store.ts @@ -30,7 +30,7 @@ import { DecodedStacksBlock } from '../stacks-core/stacks-core-block-processor.j * they can be arbitrarily large, and postgres throws `interval out of range` when converting them, * which would abort block ingestion. */ -const MAX_TOKEN_TTL_SECONDS = 3_153_600_000; +export const MAX_TOKEN_TTL_SECONDS = 3_153_600_000; export class StacksCorePgStore extends BasePgStoreModule { /** diff --git a/src/token-processor/util/fetch-header-policy.ts b/src/token-processor/util/fetch-header-policy.ts index f4152c7..8b0aa26 100644 --- a/src/token-processor/util/fetch-header-policy.ts +++ b/src/token-processor/util/fetch-header-policy.ts @@ -1,5 +1,9 @@ import { Dispatcher } from 'undici'; +function normalizeOrigin(origin: string): string { + return new URL(origin).origin; +} + /** * Drops the named headers from a dispatch, whatever shape undici is carrying them in: the first * dispatch gets the object the caller passed, while a redirected one gets the flat @@ -47,8 +51,9 @@ export function stripHeadersOffOrigin( headerNames: string[] ): Dispatcher.DispatcherComposeInterceptor { const drop = new Set(headerNames.map(name => name.toLowerCase())); + const normalizedOrigin = normalizeOrigin(origin); return dispatch => (opts, handler) => { - if (String(opts.origin) === origin) return dispatch(opts, handler); + if (normalizeOrigin(String(opts.origin)) === normalizedOrigin) return dispatch(opts, handler); return dispatch({ ...opts, headers: withoutHeaders(opts.headers, drop) }, handler); }; } diff --git a/tests/api/cache.test.ts b/tests/api/cache.test.ts index d006ac3..f011fbc 100644 --- a/tests/api/cache.test.ts +++ b/tests/api/cache.test.ts @@ -478,6 +478,21 @@ describe('Dynamic token cache control', () => { assert.ok(age !== undefined && age > 290 && age <= 300, `unexpected max-age: ${age}`); }); + test('a ttl longer than the cap keeps advertising the cap', async () => { + ENV.METADATA_DYNAMIC_TOKEN_MAX_CACHE_AGE = 300; + await insertTestUpdateNotification(db, { + token_id: 1, + update_mode: DbTokenUpdateMode.dynamic, + ttl: 3600, + }); + // Well past the cap but still an hour short of the token's own TTL, so the metadata provably + // can't change yet and we should keep advertising the capped lifetime. + await db.sql`UPDATE tokens SET updated_at = NOW() - INTERVAL '20 minutes' WHERE id = 1`; + + const response = await fastify.inject({ method: 'GET', url }); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(maxAge(response), 300); + }); test('dynamic token without a ttl must revalidate', async () => { await insertTestUpdateNotification(db, { token_id: 1, diff --git a/tests/stacks-core/block-processor.test.ts b/tests/stacks-core/block-processor.test.ts index 564b56b..7637d80 100644 --- a/tests/stacks-core/block-processor.test.ts +++ b/tests/stacks-core/block-processor.test.ts @@ -310,8 +310,8 @@ describe('block processor', () => { event_index: 0, }); // Re-orgs keep notification rows and only flip `canonical`. An orphaned 'frozen' event - // must not stop token 1 from being refreshed, and an orphaned 'dynamic' event must not - // make token 2 eligible. + // must not stop token 1 from being refreshed, and token 2's only notification is orphaned + // so it must not be treated as dynamic at all. await insertTestUpdateNotification(db, { token_id: 1, update_mode: DbTokenUpdateMode.frozen, diff --git a/tests/token-queue/fetch-destination-policy.test.ts b/tests/token-queue/fetch-destination-policy.test.ts index 01663e0..e1518e6 100644 --- a/tests/token-queue/fetch-destination-policy.test.ts +++ b/tests/token-queue/fetch-destination-policy.test.ts @@ -14,6 +14,7 @@ import { isBlockedIpAddress, setLoopbackAllowedForTesting, } from '../../src/token-processor/util/fetch-destination-policy.js'; +import { stripHeadersOffOrigin } from '../../src/token-processor/util/fetch-header-policy.js'; import { fetchAllMetadataLocalesFromBaseUri, fetchMetadata, @@ -370,4 +371,26 @@ describe('Fetch destination policy', () => { DbJobInvalidReason.fetchDestinationBlocked ); }); + + test('keeps headers on same-origin redirects despite default port spelling differences', () => { + let dispatchedHeaders: Dispatcher.DispatchOptions['headers'] | undefined; + const intercept = stripHeadersOffOrigin('http://example.com', ['X-Api-Key'])( + ((opts: Dispatcher.DispatchOptions) => { + dispatchedHeaders = opts.headers; + return true; + }) as Dispatcher['dispatch'] + ); + + intercept( + { + origin: 'http://example.com:80', + path: '/', + method: 'GET', + headers: { 'X-Api-Key': 'gateway-secret' }, + }, + {} as Dispatcher.DispatchHandlers + ); + + assert.deepStrictEqual(dispatchedHeaders, { 'X-Api-Key': 'gateway-secret' }); + }); });