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:
Eric Allam
2026-05-19 13:33:03 +01:00
committed by GitHub
parent 2fbac48e0d
commit 436b7a9ea1
2 changed files with 21 additions and 8 deletions
@@ -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,
},