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
18 changes: 12 additions & 6 deletions src/pg/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/pg/stacks-core-pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
7 changes: 6 additions & 1 deletion src/token-processor/util/fetch-header-policy.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
};
}
15 changes: 15 additions & 0 deletions tests/api/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions tests/stacks-core/block-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions tests/token-queue/fetch-destination-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' });
});
});
Loading