Skip to content

Commit 436b7a9

Browse files
authored
fix(webapp): fold S2 token scope into access-token cache key (#3668)
## Summary The S2 access-token cache key was `${basin}:${streamPrefix}` — purely server-derived but blind to the **scope/ops list** hardcoded one method away. When the ops list changes in code (e.g. #3644 added `trim` so `chat.agent`'s per-turn trim chain can issue `AppendRecord.trim()`), pre-deploy tokens still in cache get returned to SDK callers for up to the token's TTL (24h default), surfacing as `Operation not permitted` 403s on any op outside the old scope. ## Fix Lift the ops list to a module constant and fold its sorted-join fingerprint into the cache key: ```ts const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const; const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(","); // in getS2AccessToken const cacheKey = `${this.basin}:${this.streamPrefix}:${S2_TOKEN_OPS_FINGERPRINT}`; // in s2IssueAccessToken scope: { /* ... */ ops: [...S2_TOKEN_OPS], /* ... */ } ``` The fingerprint is derived from the single source of truth, so any future scope change auto-invalidates without anyone remembering to bump a literal version. The Unkey L1 (in-memory LRU) and L2 (Redis) layers share the same key derivation, so both reset together on the next deploy with no manual cache busting. ## Test plan - [ ] `pnpm run typecheck --filter webapp` - [ ] Run a multi-turn `chat.agent` chat via `references/ai-chat` and confirm no `chat.agent: trim failed; will retry next turn` warn span fires across turn-completes.
1 parent 2fbac48 commit 436b7a9

2 files changed

Lines changed: 21 additions & 8 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
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.

apps/webapp/app/services/realtime/s2realtimeStreams.server.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export type S2RealtimeStreamsOptions = {
3333
}>;
3434
};
3535

36+
// Ops the issued S2 access token is scoped to. `trim` is a distinct op
37+
// from `append` even though trim records are appended like any other —
38+
// without it, `AppendRecord.trim()` 403s with "Operation not permitted".
39+
// `chat.agent`'s per-turn trim chain depends on it.
40+
//
41+
// The fingerprint folds the ops list into the cache key, so any future
42+
// scope change auto-invalidates pre-deploy cached tokens.
43+
const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const;
44+
const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(",");
45+
3646
type S2IssueAccessTokenResponse = { access_token: string };
3747
type S2AppendInput = { records: { body: string }[] };
3848
type S2AppendAck = {
@@ -564,8 +574,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
564574
}
565575

566576
// Cache key includes basin so per-org basins never collide on
567-
// cached tokens. `${basin}:${prefix}` is unique per (org-basin, env).
568-
const cacheKey = `${this.basin}:${this.streamPrefix}`;
577+
// cached tokens, and the ops fingerprint so a scope change in code
578+
// (e.g. adding `trim` in #3644) auto-invalidates pre-deploy entries
579+
// instead of returning stale tokens for up to 24h.
580+
const cacheKey = `${this.basin}:${this.streamPrefix}:${S2_TOKEN_OPS_FINGERPRINT}`;
569581
const result = await this.cache.accessToken.swr(cacheKey, async () => {
570582
return this.s2IssueAccessToken(id);
571583
});
@@ -591,12 +603,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
591603
basins: {
592604
exact: this.basin,
593605
},
594-
// S2 treats `trim` as a separate op from `append` even though
595-
// trim records are appended like any other record. Verified
596-
// empirically: without `"trim"` here, `AppendRecord.trim()`
597-
// writes 403 with "Operation not permitted". `chat.agent`'s
598-
// per-turn trim chain depends on this.
599-
ops: ["append", "create-stream", "trim"],
606+
ops: [...S2_TOKEN_OPS],
600607
streams: {
601608
prefix: this.streamPrefix,
602609
},

0 commit comments

Comments
 (0)