From 5d8229b32b250bd1bb81d74b27cd74bc775b0c79 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 19 May 2026 13:19:17 +0100 Subject: [PATCH] fix(webapp): fold S2 token scope into access-token cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S2 access-token cache key was `${basin}:${streamPrefix}` — purely server-derived but not aware of the scope/ops the server hardcodes when minting. After a scope change in code (e.g. #3644 adding `trim` to the ops list), pre-deploy cached tokens still in Redis/L1 LRU keep getting returned for up to 24h, surfacing as "Operation not permitted" 403s on any operation outside the old scope. Lifting the ops list to a module constant and folding its sorted-join fingerprint into the cache key makes scope changes self-invalidating — the next deploy's first call mints fresh tokens under a new key and the stale entries TTL out without anyone busting Redis. --- .../s2-access-token-cache-ops-fingerprint.md | 6 +++++ .../realtime/s2realtimeStreams.server.ts | 23 ++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 .server-changes/s2-access-token-cache-ops-fingerprint.md diff --git a/.server-changes/s2-access-token-cache-ops-fingerprint.md b/.server-changes/s2-access-token-cache-ops-fingerprint.md new file mode 100644 index 00000000000..21937c341fd --- /dev/null +++ b/.server-changes/s2-access-token-cache-ops-fingerprint.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Include the S2 access-token scope fingerprint in its cache key so a scope change in code (e.g. adding a new op) auto-invalidates pre-deploy cached tokens instead of returning stale ones for up to 24h. diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts index 0553ef77f9b..07061071446 100644 --- a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts @@ -33,6 +33,16 @@ export type S2RealtimeStreamsOptions = { }>; }; +// Ops the issued S2 access token is scoped to. `trim` is a distinct op +// from `append` even though trim records are appended like any other — +// without it, `AppendRecord.trim()` 403s with "Operation not permitted". +// `chat.agent`'s per-turn trim chain depends on it. +// +// The fingerprint folds the ops list into the cache key, so any future +// scope change auto-invalidates pre-deploy cached tokens. +const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const; +const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(","); + type S2IssueAccessTokenResponse = { access_token: string }; type S2AppendInput = { records: { body: string }[] }; type S2AppendAck = { @@ -564,8 +574,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { } // Cache key includes basin so per-org basins never collide on - // cached tokens. `${basin}:${prefix}` is unique per (org-basin, env). - const cacheKey = `${this.basin}:${this.streamPrefix}`; + // cached tokens, and the ops fingerprint so a scope change in code + // (e.g. adding `trim` in #3644) auto-invalidates pre-deploy entries + // instead of returning stale tokens for up to 24h. + const cacheKey = `${this.basin}:${this.streamPrefix}:${S2_TOKEN_OPS_FINGERPRINT}`; const result = await this.cache.accessToken.swr(cacheKey, async () => { return this.s2IssueAccessToken(id); }); @@ -591,12 +603,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { basins: { exact: this.basin, }, - // S2 treats `trim` as a separate op from `append` even though - // trim records are appended like any other record. Verified - // empirically: without `"trim"` here, `AppendRecord.trim()` - // writes 403 with "Operation not permitted". `chat.agent`'s - // per-turn trim chain depends on this. - ops: ["append", "create-stream", "trim"], + ops: [...S2_TOKEN_OPS], streams: { prefix: this.streamPrefix, },