SCAL-319970 Add OAuth-gated list_orgs tool#166
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the list_orgs tool, which is restricted to OAuth-authenticated connections. It adds the necessary schemas, types, and service methods to search and list active Orgs from the ThoughtSpot instance. Feedback on these changes suggests fetching session info dynamically to ensure current_org_id is reliably populated, and adding a defensive nullish coalescing guard when filtering the upstream Orgs response to prevent potential runtime errors.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const results: Org[] = orgs | ||
| .filter( | ||
| (org): org is typeof org & { id: number } => | ||
| typeof org.id === "number" && org.status === "ACTIVE", | ||
| ) |
There was a problem hiding this comment.
The orgs returned from this.client.searchOrgs could potentially be null or undefined if the upstream API returns an empty or unexpected response. To prevent a runtime TypeError when calling .filter() on a nullish value, we should add a defensive nullish coalescing guard (orgs ?? []).
| const results: Org[] = orgs | |
| .filter( | |
| (org): org is typeof org & { id: number } => | |
| typeof org.id === "number" && org.status === "ACTIVE", | |
| ) | |
| const results: Org[] = (orgs ?? []) | |
| .filter( | |
| (org): org is typeof org & { id: number } => | |
| typeof org.id === "number" && org.status === "ACTIVE", | |
| ) |
1cd84a5 to
644f5d2
Compare
Add a new `list_orgs` MCP tool that lists the ACTIVE Orgs configured on the ThoughtSpot instance, available only to connections authenticated via OAuth. Auth method is per client connection (decided by the endpoint the client connects to), not per user or per deployment. To gate on it at runtime, this introduces an explicit `authMode` field on the connection `Props`: - OAuth routes (/mcp, /sse) set authMode = "oauth" - Static-token routes (/bearer/*, /token/*) set authMode = "bearer" | "token" The tool is gated with defense in depth: - Filtered out of listTools() for non-OAuth connections - Rejected in callTool() if invoked directly by a non-OAuth connection Data comes from the public POST /api/rest/2.0/orgs/search endpoint (client.searchOrgs), filtered to ACTIVE orgs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
644f5d2 to
d0fff63
Compare
…rgs-enabled Build out multi-org support on top of the OAuth-gated list_orgs tool. Tools - Add switch_org: mints an org-scoped bearer token for the requested org and makes subsequent tool calls operate against it. No pre-validation against list_orgs — a 401 from minting is surfaced as "org not accessible". - Gate both list_orgs and switch_org on OAuth AND cluster Orgs-enabled (configInfo.orgsConfiguration.enabled from session info), failing closed. Hidden from listTools and rejected in callTool when unavailable. Token flow - OAuth callback fetches the global token via session/v2/gettoken?refresh=true and stores token + refreshToken + tokenCreatedTime + tokenExpiryDuration. - getRefreshedToken sends the refresh token via the X-Refresh-Token header (verified this is what mints a fresh access token). - Org-scoped tokens are minted on demand from the global token and cached in-memory per instance. Durable active org (multi-session safe) - The active org is stored in a per-user Durable Object instance keyed by hash(refreshToken ?? accessToken) via the existing conversation-storage DO namespace. The refresh-token hash is stable across the access token's 24h rotation and shared across the multiple MCP sessions/DOs a single client opens, so a switch in one session is visible to the others. Conversation storage is re-keyed onto the same hash so it survives token refresh too. - The active org is NOT stored in props (props are rebuilt from the OAuth grant on every request and would clobber it). Tests - Org tool gate (OAuth + orgs-enabled, fail-closed), getStorageKeyHash (refresh-token preference + access-token fallback), isOrgsEnabled, searchOrgs ACTIVE filtering, fetchOrgBearerToken/getRefreshedToken delegation, switch_org persistence + 401 path, and shared active-org store across instances. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…token fix
Builds on the list_orgs/switch_org work with the token machinery needed for
multi-org to hold up in production:
- Keep-warm global token: a per-user DO alarm (on the hash(refreshToken)
__active_org__ instance) re-mints the cluster-wide token every 12h via
gettoken?refresh=true so it survives the ~24h expiry across a user absence.
Org tokens are minted lazily from this token and shared across fanned-out
sessions via the same store (mint once, reuse).
- Reactive org-token re-mint on 401: org-scoped tokens have a 30-day validity,
so a long-lived session can outlive its org token. withOrgTokenRetry wraps the
org-scoped call path (and validateConnectionWithOrgRetry the connectivity
checks) to transparently clear + re-mint the org token and retry once on a
401, instead of failing until the user re-switches. Detects both thrown and
swallowed-{error} 401 shapes; passes through untouched when no org token is in
use (a global-token 401 is a separate, reauth concern). Also fixes a
pre-existing missing await on the ping/check_connectivity validation.
- list_orgs now uses the GLOBAL token with no org header: enumerating orgs is a
cluster-level (ORG_ADMINISTRATION) operation and can fail/under-report with an
org-scoped token.
- Backward compatible: non-OAuth and orgs-disabled clusters keep the exact prior
single-org behavior (no active org, no org header, global token only).
- get_session_updates description nudges the model to suggest list_orgs/switch_org
only when no data is found AND the org tools are available (self-gating, no
extra API call on the failure path).
Tests: org suite covers the OAuth+orgs gate, shared active-org store / fan-out
reuse, keep-warm seed, the 401 re-mint (thrown + swallowed shapes, no-remint
without an org token, validateConnection path), and list_orgs global-token use.
Full suite green (680 passing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two production-hardening changes to the keep-warm token refresh, plus expanded test coverage. - Refresh interval 12h -> 11h, and re-arm the alarm on failure (not just success). Previously any refresh failure (a transient 5xx/network blip) permanently stopped the chain, so the token silently died at ~24h. Now a failed refresh at 11h is retried by the next regular alarm at ~22h elapsed — still before the ~24h expiry — a built-in second attempt with no bespoke backoff. The still-valid token is left intact on failure so reads keep working. - 14-day idle-session TTL. lastSeenAt is stamped on each tool call (new POST /touch route on the per-user DO, throttled to ~1/hour server-side; called fire-and-forget from MCPServer.callTool for OAuth sessions only). On the keep-warm alarm, if the user has been idle >= 14 days, the DO deletes the token-store + active-org + org-token state and stops re-arming — capping the per-user keep-warm cost for absent users and forcing a clean re-auth on their next use, instead of refreshing forever. Tests: - conversation-storage-server.spec.ts: 11h arm, success/failure re-arm, failure-then-success recovery, lastSeenAt preserved across refresh, idle abandonment at 14d (and NOT just under), throttled /touch (incl. no prior lastSeenAt and no-store no-op), idempotent alarm arm, and active-org-token clear semantics. Also fixed the storage delete() mock to accept a single key (matching the real DO API), which the active-org routes use. - mcp-server-orgs.spec.ts: org-token 401 retry edge cases (retry also 401s -> propagates w/o looping; non-401 passes through; benign "401" in data not treated as auth error) and activity tracking (touch on OAuth tool call, not on bearer). Full suite green (699 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…m refresh The org-token mint (fetchOrgBearerToken) and keep-warm refresh (getRefreshedToken) HTTP wiring was only covered at the service-delegation level. Add direct client-layer tests asserting the actual fetch contract: - fetchOrgBearerToken: hits /callosum/v1/v2/auth/token/fetch with org_identifier and the 30-day validity_time_in_sec (and honors an override); authenticates with the global access token; reads token from data.token or top-level token; throws (with status) on non-OK and when no token is returned. - getRefreshedToken: hits gettoken?refresh=true with Authorization + the X-Refresh-Token header (omitted when no refresh token); returns rotated access/refresh tokens; throws (with status) on non-OK and when no token. Full suite green (708 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dmin orgs/search
list_orgs called the v2 orgs/search REST endpoint, which requires
ORG_ADMINISTRATION and returns 403 ("Operation is not allowed") for regular
(non-admin) OAuth users — so a normal multi-org user couldn't list their orgs at
all, and the model misattributed the 403 to an auth-method problem.
Switch list_orgs to the user-scoped v1 endpoint
GET /callosum/v1/session/orgs?batchsize=-1&offset=-1, which returns the orgs the
authenticated user is a member of and is available to ANY user (no admin
privilege). This matches the original multi-org POC, which used this endpoint
specifically to avoid the orgs/search 403.
- thoughtspot-client: add client.listOrgs() hitting the v1 session/orgs endpoint,
mapping { orgId, orgName, description } -> Org. Authenticated with the global
token, no org header.
- thoughtspot-service: add listOrgs() delegating to the client (searchOrgs kept
for now but no longer used by the tool).
- callListOrgs: call listOrgs() instead of searchOrgs(); is_active still comes
from our shared active-org store (reflects switch_org).
Tests: client-layer (endpoint URL, orgId/orgName mapping, empty-orgs, 403 throws),
service-layer (delegation + nullish), and the org-tool suite updated to mock
listOrgs. Full suite green (713 passing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ject The keep-warm token store + active-org state were bolted onto ConversationStorageServerSQLite, conflating two unrelated concerns: ephemeral per-conversation message streaming (30-min TTL) and durable per-user token/org state (11h keep-warm + 14-day idle TTL). The shared alarm() even had to branch on which kind of instance it was. Split the token/org concern into a new Durable Object, UserTokenStoreSQLite: - New DO class + USER_TOKEN_OBJECT binding (migration v8 in wrangler.jsonc). - Owns active-org, active-org-token, token-store, and /touch, plus the keep-warm refresh alarm and 14-day idle abandonment. Its alarm() is now single-purpose (no branching). - ConversationStorageServerSQLite goes back to pure conversation streaming; its alarm() is solely the 30-min cleanup. - StorageServiceClient now takes a second namespace and routes token/org calls to USER_TOKEN_OBJECT while conversation calls stay on CONVERSATION_STORAGE_OBJECT. - getStorageService passes both bindings. No data migration needed: this is pre-prod, and the token store self-seeds from props on next connect while the active org defaults to the user's current org — so a fresh (empty) UserTokenStore instance simply re-populates on reconnect. Tests: token/org tests moved to a dedicated user-token-store-server.spec.ts (targeting the new DO), with added active-org routing cases. The conversation spec is now token-free. Full suite green (718 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n v1) The org tools live only in toolDefinitionsV2, so a v1 client never SEES list_orgs/switch_org — but postInit still applied the full org overlay (default active org, org-token mint, x-thoughtspot-orgs header on every call) to any OAuth + orgs-enabled session, including v1. That leaked org context into the v1 (legacy single-org) tool surface. Gate all org behavior on v2: - Add isV2ApiSurface(): resolves the API version (same resolveApiVersion the tool listing uses) and checks whether the resolved tool set includes the org tools, so it tracks the registry instead of hardcoding version labels. Fails closed. - areOrgToolsAvailable() now also requires isV2ApiSurface(). Since this gates the tool listing, the callTool defense-in-depth, AND (newly) the postInit overlay, a v1 session now gets no active org, no org token, and no org header — exactly the legacy single-org behavior. Tests: org tools hidden on the v1 surface despite OAuth + orgs enabled, and the org overlay is not applied (no mint, no active org) on a v1 connect. Full suite green (720 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
379ea06 to
2405783
Compare
… clobber, keep-warm self-heal
Production-hardening from a code review of the branch:
1. Tighten 401 detection. isUnauthorizedError matched a bare "401" anywhere in
the message, so unrelated content (a datasource id, a liveboard title, a row
count) could false-trigger a re-mint + blind retry. Match only the structured
"status 401" form the client emits. (A genuine HTTP 401 is rejected pre-handler
so retrying it is safe; the real hazard was misclassifying a non-401 error that
may have mutated — now eliminated.)
2. switch_org now recognizes 403 as well as 401 as "org not accessible" (access
denied commonly surfaces as 403), via the structured "status 40[13]" form
instead of a substring includes("401"). Previously a 403 fell through to a
generic "please try again".
3. Stop clobbering the org token under fan-out. POST /active-org deleted the
stored org token whenever the body carried none — and postInit re-asserts the
current org on every cold connect — so a concurrent sibling could delete a
token another session just minted, causing re-mint storms. Now the token is
cleared only when the org id actually CHANGES.
4. Keep-warm self-heals on reconnect. loadOrSeedWarmToken re-seeded from props
only when the stored token was ABSENT, so an expired-but-present token (e.g.
after the refresh chain died) stranded the user until full re-auth. Now it
re-seeds when the stored token is expired (expiresAt in the past), using the
fresh props token from the current grant.
Tests: 403 switch, "401 outside the status form" not treated as auth failure,
same-org-id preserves the token (fan-out safety), and expired-store re-seed.
Full suite green (724 passing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erbosity Addresses review feedback on the multi-org change: 1. Don't touch v1 tools. The v1 (backwards-compatibility) tool handlers — ping, get_relevant_questions, get_answer, create_liveboard, get_data_source_suggestions — and the shared getDatasources helper are reverted to be byte-identical to main: no withOrgTokenRetry, no org wrapping. The org-token re-mint wrappers now apply ONLY to v2 handlers (check_connectivity, create_analysis_session, send_session_message, create_dashboard). v1 stays exactly as it was for backwards compatibility. 2. Separate service layer. Org/token operations are pulled out of ThoughtSpotService into a dedicated OrgService (src/thoughtspot/org-service.ts) with listOrgs + fetchOrgBearerToken, paralleling the earlier storage split. The upstream metrics timing/outcome bookkeeping is factored into a shared observeUpstreamCall() in tool-metrics so neither service duplicates it. Deleted dead code: ThoughtSpotService.searchOrgs (admin endpoint, replaced by listOrgs), and getRefreshedToken (client + service + RefreshedTokens type + metric name) — the keep-warm refresh lives in the DO, this copy was unused. 3. Trim verbosity. Removed redundant one-line indirection (ensureActiveOrgLoaded -> loadActiveOrg; isOrgAccessDeniedError inlined) and tightened over-long comments. Tests: org service tests moved to org-service.spec.ts; dead-method tests removed; client retains listOrgs + fetchOrgBearerToken HTTP tests. Full suite green (717 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e single-use helper - Delete getThoughtSpotServiceWithToken (mcp-server-base): dead after the org calls moved to getOrgService; zero callers. - Inline the single-use isV2ApiSurface into areOrgToolsAvailable. - Trim verbose/redundant comments across the changed files (mcp-server.ts comment lines ~183 -> ~69; user-token-store-server ~32% -> ~16%; plus mcp-server-base, org-service, thoughtspot-client, storage-service, utils, tool-metrics). Converted multi-line doc blocks to tight single-line comments, keeping the "why" (fan-out token-preserve, 11h/24h refresh margin, the v1/v2 auth-path gotcha, the 403-avoidance rationale) and dropping "what"-restating prose. Pre-existing comments left untouched. No logic change. Full suite green (717 passing), tsc baseline, biome clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hardening
- Typed ThoughtSpot HTTP errors: new ThoughtSpotApiError carries a numeric
`status`; the client throws it from all raw-fetch handlers with the response
body kept off `.message`. Consumers branch on status via apiErrorStatus()
(401 re-mint, 401/403 org-not-accessible, reauth) instead of brittle string
matching — and no upstream body can leak into logs/responses.
- Keep-warm self-heal fix: on refresh, preserve the prior `expiresAt` when the
response omits `tokenExpiryDuration`, so expiry tracking is never dropped to
undefined (which would defeat the reconnect re-seed).
- OAuth callback: fail loudly at login if the auth response has no token, rather
than storing `undefined` and failing opaquely on the first tool call. The
manual-paste fallback now also carries refreshToken/timestamps when present.
- Remove the dead `Org.status` field (type + output schema + tool description) —
it was declared but never populated.
Tests: client error assertions check { status, message }; typed 403 path covered
end-to-end; keep-warm expiry-preservation regression test. Full suite green
(718 passing), tsc baseline, biome clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…no-op
Previously switch_org set the active org first, then minted — so a wrong or
inaccessible org_id moved the shared active org BEFORE validation. Under fan-out
that shared record is used by all of the user's sessions, so one session's failed
switch corrupted everyone's active org (and the old two-write commit left a brief
tokenless window).
Now mint first (the mint is the validation); only commit the active org on
success, and commit the id + token atomically in a single /active-org write:
- A wrong id / no access (any 4xx) returns a clear error and is a complete no-op
on the shared store — other sessions are unaffected.
- The successful commit is one atomic {id, token} write — no tokenless window,
and never a mismatched {id, other-org-token}.
setActiveOrg now takes an optional orgToken to support the atomic commit; the
postInit default path still calls it tokenless (clears only if the id changed).
Test: a 400 switch is a no-op (active org unchanged). Full suite green (719).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…T3); trim comments Make per-user org state consistent across fanned-out sessions: - F1: reload active org from the shared store on each org-aware call - F2(b): forceRemintOrgToken aborts if a sibling switched org meanwhile, so a concurrent switch isn't clobbered by a stale-org re-mint - F3: commit active org id + token atomically on a validated switch - F4: tag the datasource cache by org so a stale sibling refetches T3: re-read the keep-warm token from the store before minting/listing, so a long-lived DO doesn't use a connect-time token the alarm has rotated. Trim comments to the non-obvious "why" only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… active An org-scoped data call that fell back to the global token could break org isolation: on a cluster with org-per-url disabled, ThoughtSpot ignores the x-thoughtspot-orgs header and scopes to the global token's home org, silently serving wrong-org data. - getActiveBearerToken: when an org is active, return only its org token; throw rather than fall back to the global token (fail closed). - callTool: mint the org token up front (once per request) for every tool except list_orgs/switch_org, so all data paths have it ready — including the ones not wrapped in withOrgTokenRetry. - withOrgTokenRetry: ensure the org token before the call. - forceRemintOrgToken (F2a): mint first, then overwrite atomically, so the shared store never goes tokenless and concurrent sessions never see an active org without its token. list_orgs (user-scoped listing) and minting (org_identifier query param) keep using the global token on their own paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion/fan-out tests Correct switch_org/list_orgs descriptions: the active org is durable and shared per-user (keyed by hash(refreshToken)), not per-session — it persists across reconnects and applies to all of the user's sessions, resetting only on re-authentication or 14-day idle. The old "per-session, resets on reconnect" text was false and misled the client model. Verified live against the deployed test worker. Add integration tests covering the token-isolation and fan-out behavior: data-call org isolation (never the global token), fail-closed getActiveBearerToken, up-front mint, mint-first re-mint (F2a), fan-out last-write-wins, org-tagged datasource cache (F4), and global-token freshness (T3). Gitignore the local-only live test harnesses (scripts/smoke-multi-org.mjs, scripts/e2e-multi-org.sh). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cked, not ignored) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4080cc6 to
a72bdc4
Compare
Here's a PR-ready version that includes the Mermaid diagram and the accompanying design notes.
Multi-Org MCP — Request & Token Flow
This diagram illustrates how the org management tools (
list_orgs/switch_org), org-scoped token lifecycle, background keep-warm mechanism, and analysis conversations work together.sequenceDiagram autonumber participant Client as MCP Client participant MCP as MCP Worker participant UTS as UserTokenStore DO participant CONV as Conversation DO participant TS as ThoughtSpot Note over Client,TS: Two token types Note over Client,TS: GLOBAL = keep-warm, list_orgs, org-token minting Note over Client,TS: ORG = data APIs with x-thoughtspot-orgs header Note over Client,TS: Org behavior applies only for OAuth + Orgs-enabled + v2 rect rgba(180,180,180,0.15) Note over Client,TS: Initialization Client->>MCP: initialize Note over MCP: postInit (best effort) MCP->>UTS: GET token-store alt Unseeded or stored token expired MCP->>UTS: POST token-store (seed + arm 11h alarm) Note right of UTS: Re-seeding an expired token heals a broken refresh chain end MCP->>UTS: GET active-org alt No active org MCP->>UTS: POST active-org (session default) end MCP->>TS: Mint org token (GLOBAL) TS-->>MCP: Org-scoped token MCP->>UTS: Persist active-org token end rect rgba(180,180,180,0.15) Note over Client,TS: Every tool call Client->>MCP: Tool call MCP->>UTS: POST touch (OAuth only, throttled) Note right of UTS: Refresh 14-day idle TTL end rect rgba(180,180,180,0.15) Note over Client,TS: list_orgs Client->>MCP: list_orgs MCP->>TS: GET session orgs (GLOBAL) TS-->>MCP: User orgs MCP->>UTS: GET active-org MCP-->>Client: Orgs (active org flagged) end rect rgba(180,180,180,0.15) Note over Client,TS: switch_org (mint-first) Client->>MCP: switch_org(orgId) MCP->>TS: Mint org token (GLOBAL) alt Invalid or inaccessible org TS-->>MCP: 400 / 401 / 403 MCP-->>Client: No-op (active org unchanged) else Success TS-->>MCP: Org-scoped token MCP->>UTS: Atomically persist org + token MCP-->>Client: Switched end end rect rgba(180,180,180,0.15) Note over Client,TS: Analysis session Client->>MCP: create_analysis_session() MCP->>TS: POST conversation v2 (ORG token + org header) alt Org token stale TS-->>MCP: 401 MCP->>UTS: Evict stale org token MCP->>TS: Mint fresh org token (GLOBAL) TS-->>MCP: Fresh org token MCP->>UTS: Persist token MCP->>TS: Retry conversation once end TS-->>MCP: analytical_session_id MCP-->>Client: analytical_session_id Note over Client,MCP: Query is sent in the next step end rect rgba(180,180,180,0.15) Note over Client,TS: Send message Client->>MCP: send_session_message(session_id, message) MCP->>CONV: Initialize conversation MCP->>TS: Stream message loop Streaming TS-->>MCP: Chunk MCP->>CONV: Append chunk end MCP-->>Client: OK end rect rgba(180,180,180,0.15) Note over Client,TS: Poll updates Client->>MCP: get_session_updates() loop Until complete MCP->>CONV: GET messages CONV-->>MCP: Messages + isDone end MCP-->>Client: Session updates end rect rgba(180,180,180,0.15) Note over Client,TS: Background keep-warm Note over UTS: 11-hour alarm alt Idle for less than 14 days UTS->>TS: Refresh global token TS-->>UTS: Fresh global token Note right of UTS: Re-arm 11-hour alarm else Idle for 14 days or more Note over UTS: Delete token store and active org end end Note over MCP,UTS: Active org is shared per user Note over MCP,UTS: Durable Object serializes updates Note over MCP,UTS: Last successful switch winsKey Design Points
Two token types.
UserTokenStorealarm. Used forlist_orgsand minting org-scoped tokens. No org header is required.x-thoughtspot-orgsheader.v2-only behavior. Multi-org support is enabled only for OAuth authentication on org-enabled clusters using the v2 APIs. Bearer-token authentication and v1 APIs retain the existing single-org behavior.
Shared per-user state. Active org and cached tokens are stored in a single
UserTokenStoreDurable Object keyed byhash(refreshToken). This allows all client sessions for a user to share the same active org and avoids redundant org-token minting. The trade-off is that the active org is per user, so the most recent successfulswitch_orgbecomes the active org for all sessions.Mint-first switching.
switch_orgvalidates the requested organization by minting an org-scoped token before updating state. Invalid or inaccessible org IDs leave the active org unchanged.Resilient token handling. If an org-scoped token becomes invalid, the worker transparently re-mints it after a single 401 response and retries the request once. The background keep-warm process refreshes the global OAuth token every 11 hours, re-arms itself on failure, and removes stale state after 14 days of inactivity, requiring the user to authenticate again.