From 1ea58450ce37cd3063baaf215208cd5a4d3e9555 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:54:31 +0000 Subject: [PATCH 1/4] fix(observer-dashboard): mint scoped observer token for the live stream Workspace-key (rk_live_) logins broke the realtime stream: the engine rejects rk_live_ on GET /v1/ws and accepts only an observer token (ot_live_) with stream:read, but the dashboard forwarded the login credential straight to the socket. A workspace-key login therefore got working read-only REST but a silently dead stream. On a workspace-key login, mint (or reuse by rotation) a scoped `observer-dashboard` observer token via the admin API and use it as the stream credential, stored in a separate relaycast_ws_token cookie so the realtime socket never carries a workspace admin key. Observer-token logins already work on the stream directly. Sessions created before this change fall back to the login credential and re-mint a working stream token on next login. Completes the dashboard migration step of the four-principal observer token model (#213). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kavk2ErRdCo1M4Mo7hCvbw --- .../src/app/api/auth/login/route.ts | 38 +++- .../src/app/api/auth/logout/route.ts | 2 + .../src/app/api/auth/session/route.ts | 7 +- .../src/lib/observer-token.test.ts | 137 +++++++++++++++ .../src/lib/observer-token.ts | 164 ++++++++++++++++++ 5 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 packages/observer-dashboard/src/lib/observer-token.test.ts create mode 100644 packages/observer-dashboard/src/lib/observer-token.ts diff --git a/packages/observer-dashboard/src/app/api/auth/login/route.ts b/packages/observer-dashboard/src/app/api/auth/login/route.ts index a936edb6..088ef5b3 100644 --- a/packages/observer-dashboard/src/app/api/auth/login/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/login/route.ts @@ -4,11 +4,13 @@ import { resolveRelayServerCandidatesFromRequest, selectEngineForKey, } from '../../../../lib/relay-server'; +import { mintObserverStreamToken } from '../../../../lib/observer-token'; export const runtime = 'edge'; const COOKIE_NAME = 'relaycast_key'; const AGENT_COOKIE_NAME = 'relaycast_agent_token'; +const WS_TOKEN_COOKIE_NAME = 'relaycast_ws_token'; const ENGINE_COOKIE_NAME = 'relaycast_engine'; const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days @@ -18,13 +20,12 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days * fallback), remembers the resolved engine, and sets httpOnly cookies. * * Accepts either a full workspace key (`rk_live_...`) or a scoped, read-only - * observer token (`ot_live_...`). Both are treated identically here — the - * engine enforces the actual read-only scope restrictions for observer - * tokens on every request, this route just validates the credential and - * remembers which engine it belongs to. The WebSocket stream authenticates - * directly with whichever credential was used to log in (workspace key or - * observer token) — the engine's realtime endpoint (`GET /v1/ws`) already - * requires an observer token with `stream:read` scope. + * observer token (`ot_live_...`). The credential is used for read-only REST + * (the engine enforces observer scope restrictions on every request), but the + * realtime endpoint (`GET /v1/ws`) accepts *only* an observer token with + * `stream:read`. So when the login credential is a workspace key we mint (or + * reuse) a scoped observer token here and use that as the stream credential; + * an `ot_live_` login already works on the stream directly. */ export async function POST(request: NextRequest) { try { @@ -60,6 +61,23 @@ export async function POST(request: NextRequest) { ); } + // The workspace stream requires an observer token. A workspace-key login + // mints one on the operator's behalf; an observer-token login is already a + // valid stream credential. On mint failure, fall back to the login + // credential so REST-only login still succeeds (the stream will be refused + // by the engine, but that is no worse than not attempting the mint). + let wsToken = apiKey; + if (apiKey.startsWith('rk_live_')) { + const minted = await mintObserverStreamToken(relayServer, apiKey); + if (minted) { + wsToken = minted; + } else { + console.error( + '[api/auth/login] Failed to mint observer stream token for workspace key' + ); + } + } + const cookieStore = await cookies(); const cookieOptions = { httpOnly: true, @@ -76,6 +94,10 @@ export async function POST(request: NextRequest) { // endpoints used in this dashboard. cookieStore.set(AGENT_COOKIE_NAME, apiKey, cookieOptions); + // Stream credential (observer token) kept separate from the REST key so the + // realtime socket never carries a workspace admin key. + cookieStore.set(WS_TOKEN_COOKIE_NAME, wsToken, cookieOptions); + // Remember the resolved engine so session/check/logout target it directly // without re-probing both engines on every request. cookieStore.set(ENGINE_COOKIE_NAME, relayServer, cookieOptions); @@ -84,7 +106,7 @@ export async function POST(request: NextRequest) { success: true, apiKey, agentToken: apiKey, - wsToken: apiKey, + wsToken, baseUrl: relayServer, }); } catch (error) { diff --git a/packages/observer-dashboard/src/app/api/auth/logout/route.ts b/packages/observer-dashboard/src/app/api/auth/logout/route.ts index cefccace..9a7744b2 100644 --- a/packages/observer-dashboard/src/app/api/auth/logout/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/logout/route.ts @@ -5,6 +5,7 @@ export const runtime = 'edge'; const COOKIE_NAME = 'relaycast_key'; const AGENT_COOKIE_NAME = 'relaycast_agent_token'; +const WS_TOKEN_COOKIE_NAME = 'relaycast_ws_token'; const ENGINE_COOKIE_NAME = 'relaycast_engine'; /** @@ -16,6 +17,7 @@ export async function POST(_request: NextRequest) { cookieStore.delete(COOKIE_NAME); cookieStore.delete(AGENT_COOKIE_NAME); + cookieStore.delete(WS_TOKEN_COOKIE_NAME); cookieStore.delete(ENGINE_COOKIE_NAME); return NextResponse.json({ success: true }); } diff --git a/packages/observer-dashboard/src/app/api/auth/session/route.ts b/packages/observer-dashboard/src/app/api/auth/session/route.ts index 376ce44b..cc72738d 100644 --- a/packages/observer-dashboard/src/app/api/auth/session/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/session/route.ts @@ -11,6 +11,7 @@ export const runtime = 'edge'; const COOKIE_NAME = 'relaycast_key'; const AGENT_COOKIE_NAME = 'relaycast_agent_token'; +const WS_TOKEN_COOKIE_NAME = 'relaycast_ws_token'; const ENGINE_COOKIE_NAME = 'relaycast_engine'; const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days @@ -24,6 +25,7 @@ export async function GET(request: NextRequest) { const cookieStore = await cookies(); const apiKey = cookieStore.get(COOKIE_NAME)?.value; const agentToken = cookieStore.get(AGENT_COOKIE_NAME)?.value; + const wsToken = cookieStore.get(WS_TOKEN_COOKIE_NAME)?.value; if (!apiKey) { return NextResponse.json( @@ -74,7 +76,10 @@ export async function GET(request: NextRequest) { authenticated: true, apiKey, agentToken: agentToken ?? apiKey, - wsToken: apiKey, + // Prefer the observer stream token minted at login. Sessions created before + // the ws-token cookie existed fall back to the login credential; those + // workspace-key sessions re-mint a working stream token on next login. + wsToken: wsToken ?? apiKey, baseUrl, }); } diff --git a/packages/observer-dashboard/src/lib/observer-token.test.ts b/packages/observer-dashboard/src/lib/observer-token.test.ts new file mode 100644 index 00000000..cd351193 --- /dev/null +++ b/packages/observer-dashboard/src/lib/observer-token.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mintObserverStreamToken } from './observer-token'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function mockFetch(...responses: Array) { + const fetchMock = vi.fn(); + for (const response of responses) { + if (response instanceof Error) { + fetchMock.mockRejectedValueOnce(response); + } else { + fetchMock.mockResolvedValueOnce(response); + } + } + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('mintObserverStreamToken', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('creates a scoped observer token and returns its material', async () => { + const fetchMock = mockFetch( + jsonResponse({ ok: true, data: { id: 'ot_1', token: 'ot_live_new' } }, 201) + ); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBe('ot_live_new'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://cast.agentrelay.com/v1/observer-tokens'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toBe('Bearer rk_live_admin'); + const body = JSON.parse(init.body); + expect(body.name).toBe('observer-dashboard'); + expect(body.scopes).toContain('stream:read'); + expect(body.scopes).toContain('dms:read'); + expect(body.filters).toEqual({ include_dms: true }); + expect(typeof body.expires_at).toBe('string'); + }); + + it('reuses the existing token by rotating it on a name conflict', async () => { + const fetchMock = mockFetch( + jsonResponse( + { ok: false, error: { code: 'observer_token_name_conflict' } }, + 409 + ), + jsonResponse({ + ok: true, + data: [ + { id: 'ot_old', name: 'observer-dashboard', status: 'active' }, + { id: 'ot_dead', name: 'observer-dashboard', status: 'revoked' }, + ], + }), + jsonResponse({ ok: true, data: { id: 'ot_old' } }), + jsonResponse({ ok: true, data: { id: 'ot_old', token: 'ot_live_rotated' } }) + ); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBe('ot_live_rotated'); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://cast.agentrelay.com/v1/observer-tokens', + expect.objectContaining({ method: 'GET' }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + 'https://cast.agentrelay.com/v1/observer-tokens/ot_old', + expect.objectContaining({ method: 'PATCH' }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + 'https://cast.agentrelay.com/v1/observer-tokens/ot_old/rotate', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('returns null when creation fails for a non-conflict reason', async () => { + mockFetch(jsonResponse({ ok: false }, 401)); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBeNull(); + }); + + it('returns null when no active token exists to rotate on conflict', async () => { + mockFetch( + jsonResponse({ ok: false }, 409), + jsonResponse({ + ok: true, + data: [{ id: 'ot_dead', name: 'observer-dashboard', status: 'revoked' }], + }) + ); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBeNull(); + }); + + it('returns null when rotation fails', async () => { + mockFetch( + jsonResponse({ ok: false }, 409), + jsonResponse({ + ok: true, + data: [{ id: 'ot_old', name: 'observer-dashboard', status: 'active' }], + }), + jsonResponse({ ok: true, data: { id: 'ot_old' } }), + jsonResponse({ ok: false }, 500) + ); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBeNull(); + }); + + it('rejects token material that is not an observer token', async () => { + mockFetch( + jsonResponse({ ok: true, data: { id: 'ot_1', token: 'rk_live_leaked' } }, 201) + ); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBeNull(); + }); +}); diff --git a/packages/observer-dashboard/src/lib/observer-token.ts b/packages/observer-dashboard/src/lib/observer-token.ts new file mode 100644 index 00000000..1f4e175a --- /dev/null +++ b/packages/observer-dashboard/src/lib/observer-token.ts @@ -0,0 +1,164 @@ +/** + * Observer-token minting for workspace-key logins. + * + * The engine rejects the root workspace key (`rk_live_`) on the realtime + * endpoint (`GET /v1/ws`) — only a scoped observer token (`ot_live_`) with + * `stream:read` may open the workspace stream. So when an operator logs into + * the dashboard with a workspace admin key, we mint (or reuse) a read-only + * observer token on their behalf and hand *that* to the stream, instead of + * pushing the root key onto a long-lived browser socket. + */ + +/** Fixed cookie/name for the dashboard's per-workspace observer token. */ +const DASHBOARD_OBSERVER_TOKEN_NAME = 'observer-dashboard'; + +/** + * Token lifetime, matched to the dashboard auth cookie (30 days) so the minted + * token never expires out from under an otherwise-valid session. + */ +const DASHBOARD_OBSERVER_TOKEN_TTL_MS = 60 * 60 * 24 * 30 * 1000; + +/** + * Full read scope set, mirroring the engine's `OBSERVER_SCOPES` + * (`packages/engine/src/engine/observerToken.ts`). The dashboard is a firehose + * observer, so it needs every read scope plus `stream:read` for the stream to + * deliver all event types the operator could already see with the workspace + * key. Kept as a local constant to avoid coupling this edge route to the + * `@relaycast/types` build. + */ +const DASHBOARD_OBSERVER_SCOPES = [ + 'stream:read', + 'messages:read', + 'threads:read', + 'dms:read', + 'channels:read', + 'search:read', + 'agents:read', + 'nodes:read', + 'deliveries:read', + 'activity:read', + 'files:read', + 'reactions:read', +] as const; + +interface DashboardObserverTokenPayload { + name: string; + description: string; + scopes: readonly string[]; + filters: { include_dms: boolean }; + expires_at: string; +} + +function dashboardTokenPayload(): DashboardObserverTokenPayload { + return { + name: DASHBOARD_OBSERVER_TOKEN_NAME, + description: 'Auto-minted for the Relaycast observer dashboard live stream', + scopes: DASHBOARD_OBSERVER_SCOPES, + // The operator holds the workspace key, so DM visibility is not an + // escalation — keep the dashboard's existing firehose view intact. + filters: { include_dms: true }, + expires_at: new Date( + Date.now() + DASHBOARD_OBSERVER_TOKEN_TTL_MS + ).toISOString(), + }; +} + +async function readTokenMaterial(res: Response): Promise { + try { + const body = await res.json(); + const token = body?.data?.token; + return typeof token === 'string' && token.startsWith('ot_live_') + ? token + : null; + } catch { + return null; + } +} + +async function findExistingTokenId( + collectionUrl: string, + adminKey: string +): Promise { + const res = await fetch(collectionUrl, { + method: 'GET', + headers: { Authorization: `Bearer ${adminKey}` }, + cache: 'no-store', + }); + if (!res.ok) return null; + try { + const body = await res.json(); + const tokens: Array<{ id?: string; name?: string; status?: string }> = + Array.isArray(body?.data) ? body.data : []; + const match = tokens.find( + (t) => t?.name === DASHBOARD_OBSERVER_TOKEN_NAME && t?.status === 'active' + ); + return typeof match?.id === 'string' ? match.id : null; + } catch { + return null; + } +} + +/** + * Mint (or reuse) the dashboard's scoped observer token using a workspace admin + * key, and return the raw `ot_live_` token for the workspace stream. + * + * A single durable `observer-dashboard` token is kept per workspace: it is + * created on first login and rotated on subsequent logins. Rotation is required + * because token material is never retrievable after creation — so reuse means + * refreshing the existing token's scopes/expiry and rotating it to obtain a new + * usable secret. (Trade-off: a login invalidates any other browser still using + * the previous secret; that session re-syncs on its next `/api/auth/session` + * poll or re-login. Acceptable for an operator dashboard.) + * + * Returns `null` if minting failed; callers should let REST-only login proceed + * rather than block the operator on a stream-token failure. + */ +export async function mintObserverStreamToken( + baseUrl: string, + adminKey: string +): Promise { + const jsonAuthHeaders = { + Authorization: `Bearer ${adminKey}`, + 'Content-Type': 'application/json', + }; + const payload = dashboardTokenPayload(); + const collectionUrl = new URL('/v1/observer-tokens', baseUrl).toString(); + + const created = await fetch(collectionUrl, { + method: 'POST', + headers: jsonAuthHeaders, + cache: 'no-store', + body: JSON.stringify(payload), + }); + if (created.ok) return readTokenMaterial(created); + // Only a name conflict (token already exists) is recoverable via reuse. + if (created.status !== 409) return null; + + const existingId = await findExistingTokenId(collectionUrl, adminKey); + if (!existingId) return null; + + const tokenUrl = new URL( + `/v1/observer-tokens/${existingId}`, + baseUrl + ).toString(); + // Refresh scopes/filters/expiry so the rotated token reflects the current + // dashboard preset and never rotates into an already-expired window. + await fetch(tokenUrl, { + method: 'PATCH', + headers: jsonAuthHeaders, + cache: 'no-store', + body: JSON.stringify({ + scopes: payload.scopes, + filters: payload.filters, + expires_at: payload.expires_at, + }), + }); + + const rotated = await fetch(`${tokenUrl}/rotate`, { + method: 'POST', + headers: { Authorization: `Bearer ${adminKey}` }, + cache: 'no-store', + }); + if (!rotated.ok) return null; + return readTokenMaterial(rotated); +} From 2ef36860586e7f0a813ef46facb4a95e5423246d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:59:36 +0000 Subject: [PATCH 2/4] fix(observer-dashboard): fail soft when observer-token minting throws Wrap mintObserverStreamToken's network requests in try/catch returning null, so a fetch rejection (DNS, timeout, unreachable engine) falls back to a REST-only login instead of propagating to the login route and surfacing a hard 500. Add a test for the network-rejection path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kavk2ErRdCo1M4Mo7hCvbw --- .../src/lib/observer-token.test.ts | 9 ++ .../src/lib/observer-token.ts | 91 +++++++++++-------- 2 files changed, 60 insertions(+), 40 deletions(-) diff --git a/packages/observer-dashboard/src/lib/observer-token.test.ts b/packages/observer-dashboard/src/lib/observer-token.test.ts index cd351193..565cb66b 100644 --- a/packages/observer-dashboard/src/lib/observer-token.test.ts +++ b/packages/observer-dashboard/src/lib/observer-token.test.ts @@ -125,6 +125,15 @@ describe('mintObserverStreamToken', () => { ).resolves.toBeNull(); }); + it('fails soft to null when a network request rejects', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockFetch(new Error('dns failed')); + + await expect( + mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') + ).resolves.toBeNull(); + }); + it('rejects token material that is not an observer token', async () => { mockFetch( jsonResponse({ ok: true, data: { id: 'ot_1', token: 'rk_live_leaked' } }, 201) diff --git a/packages/observer-dashboard/src/lib/observer-token.ts b/packages/observer-dashboard/src/lib/observer-token.ts index 1f4e175a..dbe744d5 100644 --- a/packages/observer-dashboard/src/lib/observer-token.ts +++ b/packages/observer-dashboard/src/lib/observer-token.ts @@ -117,48 +117,59 @@ export async function mintObserverStreamToken( baseUrl: string, adminKey: string ): Promise { - const jsonAuthHeaders = { - Authorization: `Bearer ${adminKey}`, - 'Content-Type': 'application/json', - }; - const payload = dashboardTokenPayload(); - const collectionUrl = new URL('/v1/observer-tokens', baseUrl).toString(); + // Fail soft: any network rejection (DNS, timeout, unreachable engine) from + // the requests below returns null so the caller can fall back to a REST-only + // login instead of surfacing a hard 500. + try { + const jsonAuthHeaders = { + Authorization: `Bearer ${adminKey}`, + 'Content-Type': 'application/json', + }; + const payload = dashboardTokenPayload(); + const collectionUrl = new URL('/v1/observer-tokens', baseUrl).toString(); - const created = await fetch(collectionUrl, { - method: 'POST', - headers: jsonAuthHeaders, - cache: 'no-store', - body: JSON.stringify(payload), - }); - if (created.ok) return readTokenMaterial(created); - // Only a name conflict (token already exists) is recoverable via reuse. - if (created.status !== 409) return null; + const created = await fetch(collectionUrl, { + method: 'POST', + headers: jsonAuthHeaders, + cache: 'no-store', + body: JSON.stringify(payload), + }); + if (created.ok) return readTokenMaterial(created); + // Only a name conflict (token already exists) is recoverable via reuse. + if (created.status !== 409) return null; - const existingId = await findExistingTokenId(collectionUrl, adminKey); - if (!existingId) return null; + const existingId = await findExistingTokenId(collectionUrl, adminKey); + if (!existingId) return null; - const tokenUrl = new URL( - `/v1/observer-tokens/${existingId}`, - baseUrl - ).toString(); - // Refresh scopes/filters/expiry so the rotated token reflects the current - // dashboard preset and never rotates into an already-expired window. - await fetch(tokenUrl, { - method: 'PATCH', - headers: jsonAuthHeaders, - cache: 'no-store', - body: JSON.stringify({ - scopes: payload.scopes, - filters: payload.filters, - expires_at: payload.expires_at, - }), - }); + const tokenUrl = new URL( + `/v1/observer-tokens/${existingId}`, + baseUrl + ).toString(); + // Refresh scopes/filters/expiry so the rotated token reflects the current + // dashboard preset and never rotates into an already-expired window. + await fetch(tokenUrl, { + method: 'PATCH', + headers: jsonAuthHeaders, + cache: 'no-store', + body: JSON.stringify({ + scopes: payload.scopes, + filters: payload.filters, + expires_at: payload.expires_at, + }), + }); - const rotated = await fetch(`${tokenUrl}/rotate`, { - method: 'POST', - headers: { Authorization: `Bearer ${adminKey}` }, - cache: 'no-store', - }); - if (!rotated.ok) return null; - return readTokenMaterial(rotated); + const rotated = await fetch(`${tokenUrl}/rotate`, { + method: 'POST', + headers: { Authorization: `Bearer ${adminKey}` }, + cache: 'no-store', + }); + if (!rotated.ok) return null; + return readTokenMaterial(rotated); + } catch (error) { + console.error( + '[mintObserverStreamToken] Failed to mint/rotate observer token:', + error + ); + return null; + } } From 61e49566b4f32658d321097533028d77012b3f2a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 11:49:50 +0000 Subject: [PATCH 3/4] fix(observer-dashboard): per-login observer tokens, keep admin key off the socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback on the observer stream-token strategy: - P1: never use the workspace admin key as the stream credential. On mint failure the stream token is left unset and the realtime socket does not connect, instead of falling back to `apiKey`. The session route no longer backfills the ws token from the admin key, and the provider passes the observer token (never the admin key) as the socket credential — REST reads still use `apiKey`. - Replace the single fixed-name, rotate-on-login token with a fresh, uniquely-named token minted per login (30-day expiry). This removes the permanent lockout when the token is revoked (unique names never collide with the engine's (workspace_id, name) unique index) and the cross-session invalidation caused by rotation, and drops the PATCH-before-rotate step whose unchecked response could leave a stale expiry. - Revoke the minted token on logout (best effort) so a leaked ws cookie value stops working immediately rather than lingering until expiry. Update tests for the new create-only mint (returns token + id) and the revoke helper. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kavk2ErRdCo1M4Mo7hCvbw --- .../src/app/api/auth/login/route.ts | 45 ++-- .../src/app/api/auth/logout/route.ts | 28 ++- .../src/app/api/auth/session/route.ts | 10 +- .../src/components/RelaySessionProvider.tsx | 15 +- .../src/lib/observer-token.test.ts | 117 +++++------ .../src/lib/observer-token.ts | 198 ++++++++---------- 6 files changed, 211 insertions(+), 202 deletions(-) diff --git a/packages/observer-dashboard/src/app/api/auth/login/route.ts b/packages/observer-dashboard/src/app/api/auth/login/route.ts index 088ef5b3..ef0fef5f 100644 --- a/packages/observer-dashboard/src/app/api/auth/login/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/login/route.ts @@ -11,6 +11,7 @@ export const runtime = 'edge'; const COOKIE_NAME = 'relaycast_key'; const AGENT_COOKIE_NAME = 'relaycast_agent_token'; const WS_TOKEN_COOKIE_NAME = 'relaycast_ws_token'; +const WS_TOKEN_ID_COOKIE_NAME = 'relaycast_ws_token_id'; const ENGINE_COOKIE_NAME = 'relaycast_engine'; const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days @@ -23,9 +24,13 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days * observer token (`ot_live_...`). The credential is used for read-only REST * (the engine enforces observer scope restrictions on every request), but the * realtime endpoint (`GET /v1/ws`) accepts *only* an observer token with - * `stream:read`. So when the login credential is a workspace key we mint (or - * reuse) a scoped observer token here and use that as the stream credential; - * an `ot_live_` login already works on the stream directly. + * `stream:read`. So when the login credential is a workspace key we mint a + * scoped observer token here and use that as the stream credential; an + * `ot_live_` login already works on the stream directly. + * + * The workspace admin key is never used as the stream credential: if minting + * fails, the stream token is left unset (the realtime socket simply does not + * connect) rather than handing an admin key to a long-lived browser socket. */ export async function POST(request: NextRequest) { try { @@ -61,16 +66,19 @@ export async function POST(request: NextRequest) { ); } - // The workspace stream requires an observer token. A workspace-key login - // mints one on the operator's behalf; an observer-token login is already a - // valid stream credential. On mint failure, fall back to the login - // credential so REST-only login still succeeds (the stream will be refused - // by the engine, but that is no worse than not attempting the mint). - let wsToken = apiKey; - if (apiKey.startsWith('rk_live_')) { + // Resolve the stream credential. An observer-token login is already a valid + // stream credential; a workspace-key login mints a scoped observer token. + // The admin key is never used for the stream — on mint failure the stream + // token stays unset (null) and the realtime socket does not connect. + let wsToken: string | null = null; + let wsTokenId: string | null = null; + if (apiKey.startsWith('ot_live_')) { + wsToken = apiKey; + } else { const minted = await mintObserverStreamToken(relayServer, apiKey); if (minted) { - wsToken = minted; + wsToken = minted.token; + wsTokenId = minted.id; } else { console.error( '[api/auth/login] Failed to mint observer stream token for workspace key' @@ -95,8 +103,19 @@ export async function POST(request: NextRequest) { cookieStore.set(AGENT_COOKIE_NAME, apiKey, cookieOptions); // Stream credential (observer token) kept separate from the REST key so the - // realtime socket never carries a workspace admin key. - cookieStore.set(WS_TOKEN_COOKIE_NAME, wsToken, cookieOptions); + // realtime socket never carries a workspace admin key. When there is no + // stream token, clear any stale cookies rather than leaving an old one. + if (wsToken) { + cookieStore.set(WS_TOKEN_COOKIE_NAME, wsToken, cookieOptions); + } else { + cookieStore.delete(WS_TOKEN_COOKIE_NAME); + } + // Remember the minted token id so logout can revoke it on the engine. + if (wsTokenId) { + cookieStore.set(WS_TOKEN_ID_COOKIE_NAME, wsTokenId, cookieOptions); + } else { + cookieStore.delete(WS_TOKEN_ID_COOKIE_NAME); + } // Remember the resolved engine so session/check/logout target it directly // without re-probing both engines on every request. diff --git a/packages/observer-dashboard/src/app/api/auth/logout/route.ts b/packages/observer-dashboard/src/app/api/auth/logout/route.ts index 9a7744b2..aa92f3cf 100644 --- a/packages/observer-dashboard/src/app/api/auth/logout/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/logout/route.ts @@ -1,23 +1,47 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; +import { + pickRememberedEngine, + resolveRelayServerCandidatesFromRequest, +} from '../../../../lib/relay-server'; +import { revokeObserverStreamToken } from '../../../../lib/observer-token'; export const runtime = 'edge'; const COOKIE_NAME = 'relaycast_key'; const AGENT_COOKIE_NAME = 'relaycast_agent_token'; const WS_TOKEN_COOKIE_NAME = 'relaycast_ws_token'; +const WS_TOKEN_ID_COOKIE_NAME = 'relaycast_ws_token_id'; const ENGINE_COOKIE_NAME = 'relaycast_engine'; /** * POST /api/auth/logout - * Clears auth cookies. + * Revokes the observer token minted for this session (best effort) and clears + * auth cookies. */ -export async function POST(_request: NextRequest) { +export async function POST(request: NextRequest) { const cookieStore = await cookies(); + // Best-effort: revoke the minted observer token so a leaked ws cookie value + // stops working immediately instead of lingering until its 30-day expiry. + // Only workspace-key sessions mint a token (and store its id + the admin key + // needed to revoke it); `ot_live_` logins have no id cookie and are skipped. + const wsTokenId = cookieStore.get(WS_TOKEN_ID_COOKIE_NAME)?.value; + const adminKey = cookieStore.get(COOKIE_NAME)?.value; + if (wsTokenId && adminKey?.startsWith('rk_live_')) { + const baseUrl = pickRememberedEngine( + cookieStore.get(ENGINE_COOKIE_NAME)?.value, + resolveRelayServerCandidatesFromRequest(request) + ); + if (baseUrl) { + await revokeObserverStreamToken(baseUrl, adminKey, wsTokenId); + } + } + cookieStore.delete(COOKIE_NAME); cookieStore.delete(AGENT_COOKIE_NAME); cookieStore.delete(WS_TOKEN_COOKIE_NAME); + cookieStore.delete(WS_TOKEN_ID_COOKIE_NAME); cookieStore.delete(ENGINE_COOKIE_NAME); return NextResponse.json({ success: true }); } diff --git a/packages/observer-dashboard/src/app/api/auth/session/route.ts b/packages/observer-dashboard/src/app/api/auth/session/route.ts index cc72738d..228d929d 100644 --- a/packages/observer-dashboard/src/app/api/auth/session/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/session/route.ts @@ -76,10 +76,12 @@ export async function GET(request: NextRequest) { authenticated: true, apiKey, agentToken: agentToken ?? apiKey, - // Prefer the observer stream token minted at login. Sessions created before - // the ws-token cookie existed fall back to the login credential; those - // workspace-key sessions re-mint a working stream token on next login. - wsToken: wsToken ?? apiKey, + // The stream credential is the observer token minted at login (or an + // `ot_live_` login credential). It is intentionally NOT backfilled from the + // workspace admin key: the realtime socket must never carry an admin key. + // A workspace-key session with no ws token (mint failed, or a session + // created before this cookie existed) has no live stream until next login. + wsToken: wsToken ?? null, baseUrl, }); } diff --git a/packages/observer-dashboard/src/components/RelaySessionProvider.tsx b/packages/observer-dashboard/src/components/RelaySessionProvider.tsx index e14f7d2a..95707f78 100644 --- a/packages/observer-dashboard/src/components/RelaySessionProvider.tsx +++ b/packages/observer-dashboard/src/components/RelaySessionProvider.tsx @@ -9,7 +9,7 @@ import { resetActivityIfWorkspaceChanged } from '../lib/activity-store'; interface Session { apiKey: string; agentToken: string; - wsToken: string; + wsToken: string | null; baseUrl: string; } @@ -53,7 +53,9 @@ export function RelaySessionProvider({ children }: { children: React.ReactNode } setSession({ apiKey: data.apiKey, agentToken: data.agentToken, - wsToken: data.wsToken ?? data.apiKey, + // Never fall back to the REST/admin credential for the socket; a + // missing stream token means the realtime stream stays offline. + wsToken: data.wsToken ?? null, baseUrl: data.baseUrl, }); if (keyParam) router.replace('/'); @@ -86,8 +88,13 @@ export function RelaySessionProvider({ children }: { children: React.ReactNode } return ( diff --git a/packages/observer-dashboard/src/lib/observer-token.test.ts b/packages/observer-dashboard/src/lib/observer-token.test.ts index 565cb66b..e5cd5b80 100644 --- a/packages/observer-dashboard/src/lib/observer-token.test.ts +++ b/packages/observer-dashboard/src/lib/observer-token.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { mintObserverStreamToken } from './observer-token'; +import { + mintObserverStreamToken, + revokeObserverStreamToken, +} from './observer-token'; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -27,14 +30,14 @@ describe('mintObserverStreamToken', () => { vi.restoreAllMocks(); }); - it('creates a scoped observer token and returns its material', async () => { + it('creates a fresh, uniquely-named scoped token and returns token + id', async () => { const fetchMock = mockFetch( jsonResponse({ ok: true, data: { id: 'ot_1', token: 'ot_live_new' } }, 201) ); await expect( mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') - ).resolves.toBe('ot_live_new'); + ).resolves.toEqual({ token: 'ot_live_new', id: 'ot_1' }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; @@ -42,52 +45,28 @@ describe('mintObserverStreamToken', () => { expect(init.method).toBe('POST'); expect(init.headers.Authorization).toBe('Bearer rk_live_admin'); const body = JSON.parse(init.body); - expect(body.name).toBe('observer-dashboard'); + expect(body.name).toMatch(/^observer-dashboard-/); expect(body.scopes).toContain('stream:read'); expect(body.scopes).toContain('dms:read'); expect(body.filters).toEqual({ include_dms: true }); expect(typeof body.expires_at).toBe('string'); }); - it('reuses the existing token by rotating it on a name conflict', async () => { + it('mints a distinct token name on each call', async () => { const fetchMock = mockFetch( - jsonResponse( - { ok: false, error: { code: 'observer_token_name_conflict' } }, - 409 - ), - jsonResponse({ - ok: true, - data: [ - { id: 'ot_old', name: 'observer-dashboard', status: 'active' }, - { id: 'ot_dead', name: 'observer-dashboard', status: 'revoked' }, - ], - }), - jsonResponse({ ok: true, data: { id: 'ot_old' } }), - jsonResponse({ ok: true, data: { id: 'ot_old', token: 'ot_live_rotated' } }) + jsonResponse({ ok: true, data: { id: 'ot_1', token: 'ot_live_a' } }, 201), + jsonResponse({ ok: true, data: { id: 'ot_2', token: 'ot_live_b' } }, 201) ); - await expect( - mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') - ).resolves.toBe('ot_live_rotated'); + await mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin'); + await mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin'); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - 'https://cast.agentrelay.com/v1/observer-tokens', - expect.objectContaining({ method: 'GET' }) - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 3, - 'https://cast.agentrelay.com/v1/observer-tokens/ot_old', - expect.objectContaining({ method: 'PATCH' }) - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 4, - 'https://cast.agentrelay.com/v1/observer-tokens/ot_old/rotate', - expect.objectContaining({ method: 'POST' }) - ); + const nameA = JSON.parse(fetchMock.mock.calls[0][1].body).name; + const nameB = JSON.parse(fetchMock.mock.calls[1][1].body).name; + expect(nameA).not.toBe(nameB); }); - it('returns null when creation fails for a non-conflict reason', async () => { + it('returns null when creation fails', async () => { mockFetch(jsonResponse({ ok: false }, 401)); await expect( @@ -95,52 +74,60 @@ describe('mintObserverStreamToken', () => { ).resolves.toBeNull(); }); - it('returns null when no active token exists to rotate on conflict', async () => { - mockFetch( - jsonResponse({ ok: false }, 409), - jsonResponse({ - ok: true, - data: [{ id: 'ot_dead', name: 'observer-dashboard', status: 'revoked' }], - }) - ); + it('fails soft to null when the network request rejects', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockFetch(new Error('dns failed')); await expect( mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') ).resolves.toBeNull(); }); - it('returns null when rotation fails', async () => { + it('rejects a response missing token material or id', async () => { mockFetch( - jsonResponse({ ok: false }, 409), - jsonResponse({ - ok: true, - data: [{ id: 'ot_old', name: 'observer-dashboard', status: 'active' }], - }), - jsonResponse({ ok: true, data: { id: 'ot_old' } }), - jsonResponse({ ok: false }, 500) + jsonResponse({ ok: true, data: { id: 'ot_1', token: 'rk_live_leaked' } }, 201) ); await expect( mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') ).resolves.toBeNull(); }); +}); - it('fails soft to null when a network request rejects', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - mockFetch(new Error('dns failed')); - - await expect( - mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') - ).resolves.toBeNull(); +describe('revokeObserverStreamToken', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); - it('rejects token material that is not an observer token', async () => { - mockFetch( - jsonResponse({ ok: true, data: { id: 'ot_1', token: 'rk_live_leaked' } }, 201) + it('sends DELETE for the token id with the admin key', async () => { + const fetchMock = mockFetch(new Response(null, { status: 204 })); + + await revokeObserverStreamToken( + 'https://cast.agentrelay.com', + 'rk_live_admin', + 'ot_1' ); + expect(fetchMock).toHaveBeenCalledWith( + 'https://cast.agentrelay.com/v1/observer-tokens/ot_1', + expect.objectContaining({ + method: 'DELETE', + headers: { Authorization: 'Bearer rk_live_admin' }, + }) + ); + }); + + it('swallows network errors so logout can still clear cookies', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockFetch(new Error('unreachable')); + await expect( - mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin') - ).resolves.toBeNull(); + revokeObserverStreamToken( + 'https://cast.agentrelay.com', + 'rk_live_admin', + 'ot_1' + ) + ).resolves.toBeUndefined(); }); }); diff --git a/packages/observer-dashboard/src/lib/observer-token.ts b/packages/observer-dashboard/src/lib/observer-token.ts index dbe744d5..c781fe0e 100644 --- a/packages/observer-dashboard/src/lib/observer-token.ts +++ b/packages/observer-dashboard/src/lib/observer-token.ts @@ -4,13 +4,20 @@ * The engine rejects the root workspace key (`rk_live_`) on the realtime * endpoint (`GET /v1/ws`) — only a scoped observer token (`ot_live_`) with * `stream:read` may open the workspace stream. So when an operator logs into - * the dashboard with a workspace admin key, we mint (or reuse) a read-only - * observer token on their behalf and hand *that* to the stream, instead of - * pushing the root key onto a long-lived browser socket. + * the dashboard with a workspace admin key, we mint a read-only observer token + * on their behalf and hand *that* to the stream, so the socket never carries a + * workspace admin key. + * + * Each login mints a fresh, uniquely-named token (revoked on logout, expiring + * after 30 days). This deliberately avoids reusing a single fixed-name token: + * a shared token would have to be rotated to hand out usable material (token + * secrets are never retrievable after creation), which invalidates other live + * dashboard sessions, and a revoked fixed name would permanently block + * re-minting under the engine's unique (workspace_id, name) constraint. */ -/** Fixed cookie/name for the dashboard's per-workspace observer token. */ -const DASHBOARD_OBSERVER_TOKEN_NAME = 'observer-dashboard'; +/** Name prefix for tokens this dashboard mints; each mint appends a unique id. */ +const DASHBOARD_OBSERVER_TOKEN_PREFIX = 'observer-dashboard'; /** * Token lifetime, matched to the dashboard auth cookie (30 days) so the minted @@ -41,135 +48,98 @@ const DASHBOARD_OBSERVER_SCOPES = [ 'reactions:read', ] as const; -interface DashboardObserverTokenPayload { - name: string; - description: string; - scopes: readonly string[]; - filters: { include_dms: boolean }; - expires_at: string; -} - -function dashboardTokenPayload(): DashboardObserverTokenPayload { - return { - name: DASHBOARD_OBSERVER_TOKEN_NAME, - description: 'Auto-minted for the Relaycast observer dashboard live stream', - scopes: DASHBOARD_OBSERVER_SCOPES, - // The operator holds the workspace key, so DM visibility is not an - // escalation — keep the dashboard's existing firehose view intact. - filters: { include_dms: true }, - expires_at: new Date( - Date.now() + DASHBOARD_OBSERVER_TOKEN_TTL_MS - ).toISOString(), - }; -} - -async function readTokenMaterial(res: Response): Promise { - try { - const body = await res.json(); - const token = body?.data?.token; - return typeof token === 'string' && token.startsWith('ot_live_') - ? token - : null; - } catch { - return null; - } -} - -async function findExistingTokenId( - collectionUrl: string, - adminKey: string -): Promise { - const res = await fetch(collectionUrl, { - method: 'GET', - headers: { Authorization: `Bearer ${adminKey}` }, - cache: 'no-store', - }); - if (!res.ok) return null; - try { - const body = await res.json(); - const tokens: Array<{ id?: string; name?: string; status?: string }> = - Array.isArray(body?.data) ? body.data : []; - const match = tokens.find( - (t) => t?.name === DASHBOARD_OBSERVER_TOKEN_NAME && t?.status === 'active' - ); - return typeof match?.id === 'string' ? match.id : null; - } catch { - return null; - } +export interface MintedObserverToken { + /** Raw `ot_live_` token material for the workspace stream. */ + token: string; + /** Observer-token id (`ot_...`), used to revoke the token on logout. */ + id: string; } /** - * Mint (or reuse) the dashboard's scoped observer token using a workspace admin - * key, and return the raw `ot_live_` token for the workspace stream. - * - * A single durable `observer-dashboard` token is kept per workspace: it is - * created on first login and rotated on subsequent logins. Rotation is required - * because token material is never retrievable after creation — so reuse means - * refreshing the existing token's scopes/expiry and rotating it to obtain a new - * usable secret. (Trade-off: a login invalidates any other browser still using - * the previous secret; that session re-syncs on its next `/api/auth/session` - * poll or re-login. Acceptable for an operator dashboard.) + * Mint a fresh, uniquely-named scoped observer token using a workspace admin + * key, for the workspace stream. * - * Returns `null` if minting failed; callers should let REST-only login proceed - * rather than block the operator on a stream-token failure. + * Returns `{ token, id }`, or `null` if minting failed (network error or a + * non-2xx response). Callers should let REST-only login proceed on `null` + * rather than block the operator — and must NOT fall back to the workspace + * admin key for the stream credential. */ export async function mintObserverStreamToken( baseUrl: string, adminKey: string -): Promise { - // Fail soft: any network rejection (DNS, timeout, unreachable engine) from - // the requests below returns null so the caller can fall back to a REST-only - // login instead of surfacing a hard 500. +): Promise { + // Fail soft: any network rejection (DNS, timeout, unreachable engine) returns + // null so the caller can fall back to a REST-only login instead of surfacing + // a hard 500. try { - const jsonAuthHeaders = { - Authorization: `Bearer ${adminKey}`, - 'Content-Type': 'application/json', + const payload = { + name: `${DASHBOARD_OBSERVER_TOKEN_PREFIX}-${crypto.randomUUID()}`, + description: 'Auto-minted for the Relaycast observer dashboard live stream', + scopes: DASHBOARD_OBSERVER_SCOPES, + // The operator holds the workspace key, so DM visibility is not an + // escalation — keep the dashboard's existing firehose view intact. + filters: { include_dms: true }, + expires_at: new Date( + Date.now() + DASHBOARD_OBSERVER_TOKEN_TTL_MS + ).toISOString(), }; - const payload = dashboardTokenPayload(); - const collectionUrl = new URL('/v1/observer-tokens', baseUrl).toString(); - const created = await fetch(collectionUrl, { + const res = await fetch(new URL('/v1/observer-tokens', baseUrl).toString(), { method: 'POST', - headers: jsonAuthHeaders, + headers: { + Authorization: `Bearer ${adminKey}`, + 'Content-Type': 'application/json', + }, cache: 'no-store', body: JSON.stringify(payload), }); - if (created.ok) return readTokenMaterial(created); - // Only a name conflict (token already exists) is recoverable via reuse. - if (created.status !== 409) return null; + if (!res.ok) return null; - const existingId = await findExistingTokenId(collectionUrl, adminKey); - if (!existingId) return null; - - const tokenUrl = new URL( - `/v1/observer-tokens/${existingId}`, - baseUrl - ).toString(); - // Refresh scopes/filters/expiry so the rotated token reflects the current - // dashboard preset and never rotates into an already-expired window. - await fetch(tokenUrl, { - method: 'PATCH', - headers: jsonAuthHeaders, - cache: 'no-store', - body: JSON.stringify({ - scopes: payload.scopes, - filters: payload.filters, - expires_at: payload.expires_at, - }), - }); - - const rotated = await fetch(`${tokenUrl}/rotate`, { - method: 'POST', - headers: { Authorization: `Bearer ${adminKey}` }, - cache: 'no-store', - }); - if (!rotated.ok) return null; - return readTokenMaterial(rotated); + const body = await res.json(); + const token = body?.data?.token; + const id = body?.data?.id; + if ( + typeof token === 'string' && + token.startsWith('ot_live_') && + typeof id === 'string' && + id.length > 0 + ) { + return { token, id }; + } + return null; } catch (error) { console.error( - '[mintObserverStreamToken] Failed to mint/rotate observer token:', + '[mintObserverStreamToken] Failed to mint observer token:', error ); return null; } } + +/** + * Best-effort revocation of a minted observer token on logout, so a leaked ws + * cookie value stops working immediately instead of lingering until expiry. + * Requires the workspace admin key (only it may revoke observer tokens). Any + * failure is swallowed — logout must clear cookies regardless. + */ +export async function revokeObserverStreamToken( + baseUrl: string, + adminKey: string, + tokenId: string +): Promise { + try { + await fetch( + new URL(`/v1/observer-tokens/${tokenId}`, baseUrl).toString(), + { + method: 'DELETE', + headers: { Authorization: `Bearer ${adminKey}` }, + cache: 'no-store', + } + ); + } catch (error) { + console.error( + '[revokeObserverStreamToken] Failed to revoke observer token:', + error + ); + } +} From d0f98e99c5f7be14c16ca36d9a6d9c8f805e2b99 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 11:55:32 +0000 Subject: [PATCH 4/4] fix(observer-dashboard): revoke prior stream token on re-login; encode revoke id Address cubic follow-up review: - Repeated workspace-key logins overwrote relaycast_ws_token_id without revoking the previous token, orphaning it until its 30-day expiry. Revoke the previous session's token id before replacing the cookie (best effort). - Percent-encode the token id in the logout revoke URL so a malformed id can never redirect the DELETE to a different path. - Rename a test to reflect what it asserts (non-observer token in response is rejected). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kavk2ErRdCo1M4Mo7hCvbw --- .../src/app/api/auth/login/route.ts | 18 +++++++++++++++++- .../src/lib/observer-token.test.ts | 2 +- .../src/lib/observer-token.ts | 5 ++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/observer-dashboard/src/app/api/auth/login/route.ts b/packages/observer-dashboard/src/app/api/auth/login/route.ts index ef0fef5f..1cee23bb 100644 --- a/packages/observer-dashboard/src/app/api/auth/login/route.ts +++ b/packages/observer-dashboard/src/app/api/auth/login/route.ts @@ -4,7 +4,10 @@ import { resolveRelayServerCandidatesFromRequest, selectEngineForKey, } from '../../../../lib/relay-server'; -import { mintObserverStreamToken } from '../../../../lib/observer-token'; +import { + mintObserverStreamToken, + revokeObserverStreamToken, +} from '../../../../lib/observer-token'; export const runtime = 'edge'; @@ -110,6 +113,19 @@ export async function POST(request: NextRequest) { } else { cookieStore.delete(WS_TOKEN_COOKIE_NAME); } + + // Revoke the observer token minted for this browser's previous session + // before overwriting its id — otherwise repeated workspace-key logins + // orphan tokens that stay active until their 30-day expiry. Best effort; + // only a workspace key can revoke, and a different key just 404s harmlessly. + const previousWsTokenId = cookieStore.get(WS_TOKEN_ID_COOKIE_NAME)?.value; + if ( + previousWsTokenId && + previousWsTokenId !== wsTokenId && + apiKey.startsWith('rk_live_') + ) { + await revokeObserverStreamToken(relayServer, apiKey, previousWsTokenId); + } // Remember the minted token id so logout can revoke it on the engine. if (wsTokenId) { cookieStore.set(WS_TOKEN_ID_COOKIE_NAME, wsTokenId, cookieOptions); diff --git a/packages/observer-dashboard/src/lib/observer-token.test.ts b/packages/observer-dashboard/src/lib/observer-token.test.ts index e5cd5b80..b637c130 100644 --- a/packages/observer-dashboard/src/lib/observer-token.test.ts +++ b/packages/observer-dashboard/src/lib/observer-token.test.ts @@ -83,7 +83,7 @@ describe('mintObserverStreamToken', () => { ).resolves.toBeNull(); }); - it('rejects a response missing token material or id', async () => { + it('rejects a non-observer-token in the response', async () => { mockFetch( jsonResponse({ ok: true, data: { id: 'ot_1', token: 'rk_live_leaked' } }, 201) ); diff --git a/packages/observer-dashboard/src/lib/observer-token.ts b/packages/observer-dashboard/src/lib/observer-token.ts index c781fe0e..ddec19c5 100644 --- a/packages/observer-dashboard/src/lib/observer-token.ts +++ b/packages/observer-dashboard/src/lib/observer-token.ts @@ -129,7 +129,10 @@ export async function revokeObserverStreamToken( ): Promise { try { await fetch( - new URL(`/v1/observer-tokens/${tokenId}`, baseUrl).toString(), + new URL( + `/v1/observer-tokens/${encodeURIComponent(tokenId)}`, + baseUrl + ).toString(), { method: 'DELETE', headers: { Authorization: `Bearer ${adminKey}` },