Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions packages/observer-dashboard/src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import {
resolveRelayServerCandidatesFromRequest,
selectEngineForKey,
} from '../../../../lib/relay-server';
import {
mintObserverStreamToken,
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';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days

Expand All @@ -18,13 +24,16 @@ 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 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 {
Expand Down Expand Up @@ -60,6 +69,26 @@ export async function POST(request: NextRequest) {
);
}

// 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.token;
wsTokenId = minted.id;
} else {
console.error(
'[api/auth/login] Failed to mint observer stream token for workspace key'
);
}
Comment on lines +81 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Network errors during stream-token minting crash the entire login instead of falling back gracefully

The token-minting helper can throw on network errors (fetch at observer-token.ts:127, :146, or :157), and the login route calls it without its own try/catch (route.ts:71), so the exception propagates to the outer handler and returns a 500 — blocking the operator's login entirely instead of falling back to a REST-only session.

Impact: A transient network hiccup between the dashboard edge and the engine prevents the operator from logging in at all, even though the credential was already validated.

Mechanism: mintObserverStreamToken promises null-on-failure but can throw

The function's JSDoc (packages/observer-dashboard/src/lib/observer-token.ts:113-114) says "Returns null if minting failed; callers should let REST-only login proceed." The login route's comment (packages/observer-dashboard/src/app/api/auth/login/route.ts:64-68) relies on this: "On mint failure, fall back to the login credential so REST-only login still succeeds."

However, mintObserverStreamToken has no top-level try/catch. Any of its three fetch calls (lines 127, 146, 157) can throw a TypeError on DNS failure, connection refused, or other network-level errors. When that happens, the exception bubbles up through the login handler's outer catch (route.ts:112-117), which returns { success: false, error: 'Login failed' } with status 500 — even though the API key was already successfully validated against the engine.

The fix is either to wrap the mintObserverStreamToken call in the login route with its own try/catch that falls back to wsToken = apiKey, or to add a top-level try/catch inside mintObserverStreamToken itself that returns null on any unexpected error.

Suggested change
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'
);
}
let minted: string | null = null;
try {
minted = await mintObserverStreamToken(relayServer, apiKey);
} catch (err) {
console.error(
'[api/auth/login] Error minting observer stream token:', err
);
}
if (minted) {
wsToken = minted;
} else {
console.error(
'[api/auth/login] Failed to mint observer stream token for workspace key'
);
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

const cookieStore = await cookies();
const cookieOptions = {
httpOnly: true,
Expand All @@ -76,6 +105,34 @@ 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. 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);
}

// 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);

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.

P2: Switching a browser from one workspace-key login to another can leave the prior workspace's stream token active until its 30-day expiry. This revocation uses the new workspace's relayServer/apiKey, but the engine revokes observer tokens only within the authenticated workspace; consider capturing the previous relaycast_key/engine before overwriting cookies and revoking with those credentials.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/observer-dashboard/src/app/api/auth/login/route.ts, line 127:

<comment>Switching a browser from one workspace-key login to another can leave the prior workspace's stream token active until its 30-day expiry. This revocation uses the new workspace's `relayServer`/`apiKey`, but the engine revokes observer tokens only within the authenticated workspace; consider capturing the previous `relaycast_key`/engine before overwriting cookies and revoking with those credentials.</comment>

<file context>
@@ -110,6 +113,19 @@ export async function POST(request: NextRequest) {
+      previousWsTokenId !== wsTokenId &&
+      apiKey.startsWith('rk_live_')
+    ) {
+      await revokeObserverStreamToken(relayServer, apiKey, previousWsTokenId);
+    }
     // Remember the minted token id so logout can revoke it on the engine.
</file context>

}
// Remember the minted token id so logout can revoke it on the engine.
if (wsTokenId) {
cookieStore.set(WS_TOKEN_ID_COOKIE_NAME, wsTokenId, cookieOptions);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} 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.
cookieStore.set(ENGINE_COOKIE_NAME, relayServer, cookieOptions);
Expand All @@ -84,7 +141,7 @@ export async function POST(request: NextRequest) {
success: true,
apiKey,
agentToken: apiKey,
wsToken: apiKey,
wsToken,
baseUrl: relayServer,
});
} catch (error) {
Expand Down
30 changes: 28 additions & 2 deletions packages/observer-dashboard/src/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Logout does not revoke the minted observer token on the engine

The logout route (packages/observer-dashboard/src/app/api/auth/logout/route.ts:17-21) deletes the relaycast_ws_token cookie but does not call DELETE /v1/observer-tokens/:id on the engine to revoke the minted token. The token remains active on the server until its 30-day expiry. Since the token name is fixed (observer-dashboard) and the next login will rotate it anyway, this is unlikely to cause a functional problem — but it means a leaked cookie value remains usable until the next login or natural expiry. Whether this matters depends on the threat model for operator dashboards.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

cookieStore.delete(WS_TOKEN_ID_COOKIE_NAME);
cookieStore.delete(ENGINE_COOKIE_NAME);
return NextResponse.json({ success: true });
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand Down Expand Up @@ -74,7 +76,12 @@ export async function GET(request: NextRequest) {
authenticated: true,
apiKey,
agentToken: agentToken ?? apiKey,
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,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { resetActivityIfWorkspaceChanged } from '../lib/activity-store';
interface Session {
apiKey: string;
agentToken: string;
wsToken: string;
wsToken: string | null;
baseUrl: string;
}

Expand Down Expand Up @@ -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('/');
Expand Down Expand Up @@ -86,8 +88,13 @@ export function RelaySessionProvider({ children }: { children: React.ReactNode }
return (
<RelayProvider
apiKey={session.apiKey}
agentToken={session.agentToken}
wsToken={session.wsToken}
// The socket credential is resolved as `wsToken ?? agentToken`, so keep
// the admin key out of both: the realtime socket must only ever use the
// observer stream token (empty when there is none, so it never falls back
// to the REST/admin key). REST reads still use `apiKey`. The dashboard is
// observer-only and never uses the agent (REST-as-agent) client.
agentToken={session.wsToken ?? ''}
wsToken={session.wsToken ?? undefined}
baseUrl={session.baseUrl}
debug
>
Expand Down
133 changes: 133 additions & 0 deletions packages/observer-dashboard/src/lib/observer-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
mintObserverStreamToken,
revokeObserverStreamToken,
} 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<Response | Error>) {
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 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.toEqual({ token: 'ot_live_new', id: 'ot_1' });

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).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('mints a distinct token name on each call', async () => {
const fetchMock = mockFetch(
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 mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin');
await mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin');

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', async () => {
mockFetch(jsonResponse({ ok: false }, 401));

await expect(
mintObserverStreamToken('https://cast.agentrelay.com', 'rk_live_admin')
).resolves.toBeNull();
});

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('rejects a non-observer-token in the response', 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();
});
});

describe('revokeObserverStreamToken', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

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(
revokeObserverStreamToken(
'https://cast.agentrelay.com',
'rk_live_admin',
'ot_1'
)
).resolves.toBeUndefined();
});
});
Loading
Loading