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.
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user