Skip to content

SCAL-319970 Add OAuth-gated list_orgs tool#166

Open
rohitthughtspot wants to merge 18 commits into
thoughtspot:mainfrom
rohitthughtspot:SCAL-319970
Open

SCAL-319970 Add OAuth-gated list_orgs tool#166
rohitthughtspot wants to merge 18 commits into
thoughtspot:mainfrom
rohitthughtspot:SCAL-319970

Conversation

@rohitthughtspot

@rohitthughtspot rohitthughtspot commented Jun 23, 2026

Copy link
Copy Markdown

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 wins
Loading

Key Design Points

  • Two token types.

    • Global token: Cluster-wide OAuth token kept warm by the UserTokenStore alarm. Used for list_orgs and minting org-scoped tokens. No org header is required.
    • Org-scoped token: Bound to a single organization and used for all data APIs together with the x-thoughtspot-orgs header.
  • 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 UserTokenStore Durable Object keyed by hash(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 successful switch_org becomes the active org for all sessions.

  • Mint-first switching. switch_org validates 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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/servers/mcp-server.ts
Comment thread src/thoughtspot/thoughtspot-service.ts Outdated
Comment on lines +737 to +741
const results: Org[] = orgs
.filter(
(org): org is typeof org & { id: number } =>
typeof org.id === "number" && org.status === "ACTIVE",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 ?? []).

Suggested change
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",
)

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>
rohitthughtspot and others added 7 commits June 24, 2026 23:40
…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>
rohitthughtspot and others added 10 commits June 30, 2026 14:30
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant