Skip to content

Commit 5d8229b

Browse files
committed
fix(webapp): fold S2 token scope into access-token cache key
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.
1 parent 2f261e5 commit 5d8229b

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)