diff --git a/backend/.gitignore b/backend/.gitignore index 4d7214c0..01926296 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -4,6 +4,4 @@ dist logs/*.jsonl *.log *.pid -data/*.db -data/*.db-shm -data/*.db-wal +data/ diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 13eecf36..d081ca1f 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -31,8 +31,10 @@ thin: it proxies OpenAI requests with the server-held API key and writes study l 26 otherwise surface as silent empty 200s — see the comment in `openaiProxy.ts`; run Node 24 LTS). - **Usage metering** (`src/usage.ts`, `src/pricing.ts`): every proxied model request - writes a content-free row (user, model, token counts, status) to the `llm_usage` - table. Streaming responses are `tee()`d so usage can be read from the + writes a content-free row (user, `client_id`, model, token counts, status) to the + `llm_usage` table. `client_id` attributes the request to the tool that made it (a + registered tool's id, or null for the first-party add-in) — the billing/provenance + counterpart of the same field on study logs. Streaming responses are `tee()`d so usage can be read from the terminal SSE event — which is also why the proxy forces `stream_options.include_usage` on Chat Completions requests. Metering sits *outside* the consent levels in `consent.ts`: it's billing data, kept even at level `none`. `GET /api/usage_summary` @@ -54,6 +56,18 @@ thin: it proxies OpenAI requests with the server-held API key and writes study l `POSTHOG_PROJECT_TOKEN` is unset. `deletePosthogPerson` (used by both erasure paths) hits the PostHog *management* API and needs `POSTHOG_PERSONAL_API_KEY` + `POSTHOG_PROJECT_ID`; without them it warns and no-ops. +- **Tool launcher** (`src/toolGrants.ts`): the handoff grant flow for launching + external writing tools from the sidebar (see `docs/tool-launcher-plan.md`, Phase 1). + The signed-in taskpane mints a single-use, short-TTL grant (`POST /api/handoff`) + carrying the user, the tool's `client_id`, requested scopes, and an optional + read-only document snapshot; the tool — on its own foreign origin — swaps the + grant_id for a `wtk_`-prefixed bearer token (`POST /api/handoff/exchange`). That + token is our own credential (a `tool_grant` row, not a Better Auth session): + `app.ts` `resolveUser` recognizes the `wtk_` prefix and resolves it here, so the + tool's proxy/log calls run under the user's account, attributed to the tool. + `GET /api/handoff/doc` re-fetches the snapshot (gated by `doc:read`); + `POST /api/handoff/revoke` disconnects a token. Scope enforcement beyond the doc + re-fetch is deferred (Phase 3). - **Auth** (`src/auth.ts`): Better Auth (Google sign-in + device-code flow for the add-in), on the shared `app.db`, enabled by `BETTER_AUTH_ENABLED=true`. Carries the user's `loggingConsent` level as a user field; `beforeDelete` purges study logs and diff --git a/backend/src/__tests__/db.test.ts b/backend/src/__tests__/db.test.ts index 2059df6a..5493ec99 100644 --- a/backend/src/__tests__/db.test.ts +++ b/backend/src/__tests__/db.test.ts @@ -22,7 +22,7 @@ afterEach(() => { describe('migrations', () => { it('creates our schema and records the version', () => { const conn = db(); - expect(conn.pragma('user_version', { simple: true })).toBe(1); + expect(conn.pragma('user_version', { simple: true })).toBe(3); // The table is usable, not merely declared. const table = conn @@ -31,6 +31,20 @@ describe('migrations', () => { ) .get(); expect(table).toBeDefined(); + + // v2 added the client_id attribution column... + const usageCols = ( + conn.prepare(`PRAGMA table_info(llm_usage)`).all() as { name: string }[] + ).map((c) => c.name); + expect(usageCols).toContain('client_id'); + + // ...and v3 added the tool_grant launcher table. + const grantTable = conn + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='tool_grant'`, + ) + .get(); + expect(grantTable).toBeDefined(); }); it('is idempotent across reopens — a second open re-runs nothing', () => { @@ -44,7 +58,7 @@ describe('migrations', () => { // Reopening must not drop or recreate the table (CREATE TABLE would throw). const conn = db(); - expect(conn.pragma('user_version', { simple: true })).toBe(1); + expect(conn.pragma('user_version', { simple: true })).toBe(3); const rows = conn.prepare(`SELECT COUNT(*) AS n FROM llm_usage`).get() as { n: number; }; @@ -73,7 +87,7 @@ describe('legacy auth.db rename', () => { expect(user).toEqual({ email: 'a@b.c' }); // ...and our migrations then run on top of the adopted database. - expect(conn.pragma('user_version', { simple: true })).toBe(1); + expect(conn.pragma('user_version', { simple: true })).toBe(3); }); it('leaves an existing app.db alone when a stray auth.db is also present', async () => { diff --git a/backend/src/__tests__/erasure.test.ts b/backend/src/__tests__/erasure.test.ts index d9ca0523..8e1825fa 100644 --- a/backend/src/__tests__/erasure.test.ts +++ b/backend/src/__tests__/erasure.test.ts @@ -43,6 +43,7 @@ async function logSomething(userId: string) { event: 'saved', schema_version: 1, page: null, + client_id: null, extra_data: {}, }); } @@ -50,6 +51,7 @@ async function logSomething(userId: string) { function meterSomething(userId: string) { recordUsage({ userId, + clientId: null, provider: 'openai', endpoint: 'chat/completions', model: 'gpt-4o', diff --git a/backend/src/__tests__/handoff.test.ts b/backend/src/__tests__/handoff.test.ts new file mode 100644 index 00000000..3d7b4841 --- /dev/null +++ b/backend/src/__tests__/handoff.test.ts @@ -0,0 +1,209 @@ +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createApp } from '../app.js'; +import type { Auth } from '../auth.js'; +import { closeDb } from '../db.js'; + +const env = { ...process.env }; + +beforeEach(async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'wt-handoff-')); + process.env.DATA_DIR = dir; + vi.stubEnv('LOG_DIR', path.join(dir, 'logs')); + // The tool launcher reuses the device-client allowlist to decide who may + // receive a grant. + vi.stubEnv('BETTER_AUTH_DEVICE_CLIENT_IDS', 'mindmap,writing-tools-editor'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + closeDb(); + process.env = { ...env }; +}); + +/** App wired to a Better Auth stub whose session is `user` (or null). */ +function authApp(user: { id: string; email?: string; loggingConsent?: string } | null) { + const auth = { + handler: async () => new Response(null), + api: { getSession: async () => (user ? { user } : null) }, + } as unknown as Auth; + return createApp({ auth }); +} + +const SIGNED_IN = { id: 'usr-1', email: 'a@calvin.edu', loggingConsent: 'usage' }; + +function post(app: ReturnType, url: string, body: unknown, token?: string) { + return app.request(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/handoff', () => { + it('rejects an unauthenticated request', async () => { + const res = await post(authApp(null), '/api/handoff', { + tool_client_id: 'mindmap', + }); + expect(res.status).toBe(401); + }); + + it('rejects a tool_client_id outside the allowlist', async () => { + const res = await post(authApp(SIGNED_IN), '/api/handoff', { + tool_client_id: 'not-registered', + }); + expect(res.status).toBe(400); + }); + + it('rejects invalid scopes', async () => { + const res = await post(authApp(SIGNED_IN), '/api/handoff', { + tool_client_id: 'mindmap', + scopes: ['openai:chat', 'root:everything'], + }); + expect(res.status).toBe(400); + }); + + it('mints a grant for a signed-in user', async () => { + const res = await post(authApp(SIGNED_IN), '/api/handoff', { + tool_client_id: 'mindmap', + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { grant_id: string; expires_in: number }; + expect(body.grant_id).toMatch(/^wtg_/); + expect(body.expires_in).toBeGreaterThan(0); + }); +}); + +describe('POST /api/handoff/exchange', () => { + it('swaps a grant for a token + doc snapshot, and is single-use', async () => { + const app = authApp(SIGNED_IN); + const created = (await ( + await post(app, '/api/handoff', { + tool_client_id: 'mindmap', + doc: { beforeCursor: 'hi', selectedText: '', afterCursor: '' }, + }) + ).json()) as { grant_id: string }; + + // Exchange needs no session — the grant_id is the credential. + const res = await post(createApp(), '/api/handoff/exchange', { + grant_id: created.grant_id, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + access_token: string; + client_id: string; + scopes: string[]; + doc: unknown; + }; + expect(body.access_token).toMatch(/^wtk_/); + expect(body.client_id).toBe('mindmap'); + expect(body.doc).toEqual({ beforeCursor: 'hi', selectedText: '', afterCursor: '' }); + + // Replaying the grant fails. + const replay = await post(createApp(), '/api/handoff/exchange', { + grant_id: created.grant_id, + }); + expect(replay.status).toBe(400); + }); + + it('400s a missing grant_id', async () => { + const res = await post(createApp(), '/api/handoff/exchange', {}); + expect(res.status).toBe(400); + }); +}); + +describe('tool token end-to-end', () => { + async function tokenFor(scopes?: string[]): Promise { + const app = authApp(SIGNED_IN); + const created = (await ( + await post(app, '/api/handoff', { + tool_client_id: 'mindmap', + scopes, + doc: { beforeCursor: 'doc', selectedText: '', afterCursor: '' }, + }) + ).json()) as { grant_id: string }; + const ex = (await ( + await post(createApp(), '/api/handoff/exchange', { grant_id: created.grant_id }) + ).json()) as { access_token: string }; + return ex.access_token; + } + + it('authenticates /api/log and stamps the tool client_id on the entry', async () => { + const token = await tokenFor(); + // The wtk_ token authenticates without any Better Auth session present. + const res = await post(createApp(), '/api/log', { event: 'tool_event' }, token); + expect(res.status).toBe(200); + + const content = await readFile( + path.join(process.env.LOG_DIR as string, 'usr-1.jsonl'), + 'utf8', + ); + const entry = JSON.parse(content.trim()); + expect(entry.username).toBe('usr-1'); + expect(entry.event).toBe('tool_event'); + expect(entry.client_id).toBe('mindmap'); + }); + + it('re-fetches the doc snapshot when doc:read is granted', async () => { + const token = await tokenFor(['openai:chat', 'doc:read']); + const res = await createApp().request('/api/handoff/doc', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + expect((await res.json()) as { doc: unknown }).toEqual({ + doc: { beforeCursor: 'doc', selectedText: '', afterCursor: '' }, + }); + }); + + it('forbids the doc re-fetch without the doc:read scope', async () => { + const token = await tokenFor(['openai:chat']); + const res = await createApp().request('/api/handoff/doc', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(403); + }); + + it('revokes a token so it can no longer authenticate', async () => { + const token = await tokenFor(); + expect((await post(createApp(), '/api/handoff/revoke', {}, token)).status).toBe(200); + + const res = await post(createApp(), '/api/log', { event: 'after_revoke' }, token); + expect(res.status).toBe(401); + }); +}); + +describe('X-Client-Id header attribution (device-flow tools)', () => { + async function logWithClient(header: string | undefined): Promise { + const app = authApp(SIGNED_IN); + await app.request('/api/log', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(header ? { 'X-Client-Id': header } : {}), + }, + body: JSON.stringify({ event: 'e' }), + }); + const content = await readFile( + path.join(process.env.LOG_DIR as string, 'usr-1.jsonl'), + 'utf8', + ); + return JSON.parse(content.trim()).client_id; + } + + it('stamps an allowlisted client id', async () => { + expect(await logWithClient('writing-tools-editor')).toBe('writing-tools-editor'); + }); + + it('ignores a client id outside the allowlist (attribution can\'t be spoofed)', async () => { + expect(await logWithClient('evil-tool')).toBeNull(); + }); + + it('defaults to null (the first-party add-in) with no header', async () => { + expect(await logWithClient(undefined)).toBeNull(); + }); +}); diff --git a/backend/src/__tests__/logging.test.ts b/backend/src/__tests__/logging.test.ts index aa6b31db..8870ac2f 100644 --- a/backend/src/__tests__/logging.test.ts +++ b/backend/src/__tests__/logging.test.ts @@ -49,6 +49,7 @@ describe('appendLog', () => { event: 'click', schema_version: 1, page: 'draft', + client_id: null, extra_data: { a: 1 }, }); await appendLog({ @@ -58,6 +59,7 @@ describe('appendLog', () => { event: 'type', schema_version: 1, page: 'chat', + client_id: null, extra_data: {}, }); @@ -77,6 +79,7 @@ describe('appendLog', () => { event: 'x', schema_version: 1, page: 'draft', + client_id: null, extra_data: {}, }), ).rejects.toThrow(); @@ -121,6 +124,7 @@ describe('zipLogs', () => { event: 'x', schema_version: 1, page: 'draft', + client_id: null, extra_data: {}, }); const zip = await zipLogs(); diff --git a/backend/src/__tests__/openaiProxy.test.ts b/backend/src/__tests__/openaiProxy.test.ts index d5157106..e2d79664 100644 --- a/backend/src/__tests__/openaiProxy.test.ts +++ b/backend/src/__tests__/openaiProxy.test.ts @@ -36,6 +36,7 @@ const real = (id: string) => isAnonymous: false, loggingConsent: CONSENT_LEVELS[2], isAllowed: true, + clientId: null, }) as SessionUser; const anon = (id: string) => ({ @@ -43,6 +44,7 @@ const anon = (id: string) => isAnonymous: true, loggingConsent: FULL_CONSENT_LEVEL, isAllowed: true, + clientId: null, }) as SessionUser; diff --git a/backend/src/__tests__/toolGrants.test.ts b/backend/src/__tests__/toolGrants.test.ts new file mode 100644 index 00000000..81c99316 --- /dev/null +++ b/backend/src/__tests__/toolGrants.test.ts @@ -0,0 +1,143 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { closeDb } from '../db.js'; +import { + createToolGrant, + exchangeToolGrant, + GRANT_TTL_MS, + getToolTokenDoc, + resolveToolToken, + revokeToolToken, + TOKEN_TTL_MS, +} from '../toolGrants.js'; + +const env = { ...process.env }; + +beforeEach(async () => { + process.env.DATA_DIR = await mkdtemp(path.join(tmpdir(), 'wt-grants-')); +}); + +afterEach(() => { + vi.useRealTimers(); + closeDb(); + process.env = { ...env }; +}); + +const USER = { + id: 'usr-1', + loggingConsent: 'usage' as const, + isAnonymous: false, + isAllowed: true, +}; + +function mint(docSnapshot: unknown = undefined) { + return createToolGrant({ + user: USER, + toolClientId: 'mindmap', + scopes: ['openai:chat', 'doc:read'], + docSnapshot, + }); +} + +describe('createToolGrant / exchangeToolGrant', () => { + it('exchanges a fresh grant for a wtk_ token carrying the identity, scopes and doc', () => { + const { grantId } = mint({ beforeCursor: 'hello', selectedText: '', afterCursor: '' }); + const result = exchangeToolGrant(grantId); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.accessToken).toMatch(/^wtk_/); + expect(result.clientId).toBe('mindmap'); + expect(result.scopes).toEqual(['openai:chat', 'doc:read']); + expect(result.user.id).toBe('usr-1'); + expect(result.doc).toEqual({ beforeCursor: 'hello', selectedText: '', afterCursor: '' }); + expect(result.expiresIn).toBe(Math.floor(TOKEN_TTL_MS / 1000)); + }); + + it('is single-use — a second exchange is refused', () => { + const { grantId } = mint(); + expect(exchangeToolGrant(grantId).ok).toBe(true); + + const second = exchangeToolGrant(grantId); + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.error).toBe('already_used'); + }); + + it('rejects an unknown grant', () => { + const r = exchangeToolGrant('wtg_nope'); + expect(r).toEqual({ ok: false, error: 'not_found' }); + }); + + it('rejects an expired grant', () => { + vi.useFakeTimers(); + const { grantId } = mint(); + vi.advanceTimersByTime(GRANT_TTL_MS + 1); + + const r = exchangeToolGrant(grantId); + expect(r).toEqual({ ok: false, error: 'expired' }); + }); + + it('returns doc: null when no snapshot was shared', () => { + const { grantId } = mint(); // no doc + const r = exchangeToolGrant(grantId); + expect(r.ok && r.doc).toBeNull(); + }); +}); + +describe('resolveToolToken', () => { + it('resolves a live token to the user with the tool as clientId', () => { + const { grantId } = mint(); + const ex = exchangeToolGrant(grantId); + if (!ex.ok) throw new Error('exchange failed'); + + const resolved = resolveToolToken(ex.accessToken); + expect(resolved?.user.id).toBe('usr-1'); + expect(resolved?.user.clientId).toBe('mindmap'); + expect(resolved?.user.isAllowed).toBe(true); + expect(resolved?.scopes).toContain('doc:read'); + }); + + it('returns null for an unexchanged, unknown, or non-wtk token', () => { + const { grantId } = mint(); + // grantId is not an access token + expect(resolveToolToken(grantId)).toBeNull(); + expect(resolveToolToken('wtk_bogus')).toBeNull(); + }); + + it('returns null once the token has expired', () => { + vi.useFakeTimers(); + const { grantId } = mint(); + const ex = exchangeToolGrant(grantId); + if (!ex.ok) throw new Error('exchange failed'); + + vi.advanceTimersByTime(TOKEN_TTL_MS + 1); + expect(resolveToolToken(ex.accessToken)).toBeNull(); + }); +}); + +describe('getToolTokenDoc / revokeToolToken', () => { + it('re-fetches the stored snapshot for a live token', () => { + const { grantId } = mint({ beforeCursor: 'ctx', selectedText: '', afterCursor: '' }); + const ex = exchangeToolGrant(grantId); + if (!ex.ok) throw new Error('exchange failed'); + + expect(getToolTokenDoc(ex.accessToken)).toEqual({ + doc: { beforeCursor: 'ctx', selectedText: '', afterCursor: '' }, + }); + expect(getToolTokenDoc('wtk_unknown')).toBeUndefined(); + }); + + it('revokes a token so it no longer resolves', () => { + const { grantId } = mint(); + const ex = exchangeToolGrant(grantId); + if (!ex.ok) throw new Error('exchange failed'); + + expect(revokeToolToken(ex.accessToken)).toBe(true); + expect(resolveToolToken(ex.accessToken)).toBeNull(); + // A second revoke is a no-op. + expect(revokeToolToken(ex.accessToken)).toBe(false); + }); +}); diff --git a/backend/src/__tests__/usage.test.ts b/backend/src/__tests__/usage.test.ts index a01d6c0c..e187c31f 100644 --- a/backend/src/__tests__/usage.test.ts +++ b/backend/src/__tests__/usage.test.ts @@ -27,6 +27,7 @@ afterEach(() => { function record(overrides: Partial = {}): void { recordUsage({ userId: 'usr-1', + clientId: null, provider: 'openai', endpoint: 'chat/completions', model: 'gpt-4o', diff --git a/backend/src/app.ts b/backend/src/app.ts index 822db472..ea417407 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,15 +8,45 @@ import { filterExtraDataForConsent, isConsentLevel, } from './consent.js'; -import { gitCommit, logSecret } from './config.js'; +import { deviceClientIds, gitCommit, logSecret } from './config.js'; import { eraseLoggedData } from './erasure.js'; import { appendLog, pollLogs, zipLogs } from './logging.js'; import { openaiProxy } from './openaiProxy.js'; import { captureException, posthogMiddleware } from './posthog.js'; import { costUsd } from './pricing.js'; +import { + createToolGrant, + DEFAULT_TOOL_SCOPES, + exchangeToolGrant, + getToolTokenDoc, + isToolScope, + resolveToolToken, + revokeToolToken, + type ToolScope, +} from './toolGrants.js'; import { summarizeUsage } from './usage.js'; import { isUserAllowed } from './userAllowlist.js'; +/** Extract the raw bearer token from the Authorization header, or null. */ +function bearerToken(c: Context): string | null { + const header = c.req.header('Authorization') ?? ''; + const match = header.match(/^Bearer\s+(.+)$/i); + return match?.[1] ? match[1].trim() : null; +} + +/** + * Which client this request identifies itself as, for attribution only. A tool + * launched via the pure device flow (or the add-in itself) names its registered + * client with an `X-Client-Id` header; we honour it only when it's in the device + * allowlist, so an arbitrary label can't be injected into billing/study data. Tool + * grant tokens don't use this path — their client_id is authoritative from the grant. + */ +function headerClientId(c: Context): string | null { + const claimed = c.req.header('X-Client-Id')?.trim(); + if (claimed && deviceClientIds().includes(claimed)) return claimed; + return null; +} + // Shared gate for the researcher/operator routes (log viewer + usage summary). // Returns an error Response to short-circuit, or null when the secret is valid. function logSecretGate(c: Context, provided: string): Response | null { @@ -82,6 +112,17 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { async function resolveUser( c: Context, ): Promise { + // Tool grant tokens (see toolGrants.ts) are our own opaque credential, tagged + // with a `wtk_` prefix so they're resolved locally and never handed to Better + // Auth. They already carry the tool's client_id, so an external tool's proxy + // and log calls flow through under the user's account, attributed to the tool. + // Resolved independent of `auth` — a grant can only have been minted while auth + // was on, but the token itself verifies against our own table. + const bearer = bearerToken(c); + if (bearer?.startsWith('wtk_')) { + return resolveToolToken(bearer)?.user ?? null; + } + if (!auth) return null; const session = await auth.api.getSession({ headers: c.req.raw.headers }); if (!session) return null; @@ -105,6 +146,9 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { isAnonymous, alwaysAllow: u.alwaysAllow === true, }), + // A session-authenticated request is the add-in itself (clientId null) + // unless it self-identifies as a device-flow tool via X-Client-Id. + clientId: headerClientId(c), }; } @@ -162,6 +206,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { event, schema_version: schemaVersion, page, + client_id: user.clientId, extra_data: gated, }); } catch (e) { @@ -228,6 +273,115 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { return c.json({ message: 'Your logged activity has been erased.' }); }); + // --- Tool launcher: handoff grant flow (see toolGrants.ts) ------------------- + + // Mint a launch grant for a sidebar-launched tool. Authenticated: the signed-in + // taskpane vouches for the user and chooses which tool + scopes to grant, and may + // include a read-only document snapshot (only the taskpane can read Office.js). + // Returns a single-use grant_id the taskpane puts in the tool URL fragment. + app.post('/api/handoff', async (c) => { + const user = await resolveUser(c); + if (!user) return c.json({ detail: 'Unauthorized' }, 401); + + const body = (await c.req.json().catch(() => ({}))) as { + tool_client_id?: unknown; + scopes?: unknown; + doc?: unknown; + }; + + const toolClientId = body.tool_client_id; + // A tool is registered exactly as a device client is — reusing the allowlist + // keeps "who may receive a grant" in one place (see the plan's auth section). + if (typeof toolClientId !== 'string' || !deviceClientIds().includes(toolClientId)) { + return c.json( + { detail: 'Unknown tool_client_id.', allowed: deviceClientIds() }, + 400, + ); + } + + // Scopes: default the common read-only set; otherwise every requested scope + // must be one we recognize. + let scopes: ToolScope[]; + if (body.scopes === undefined) { + scopes = DEFAULT_TOOL_SCOPES; + } else if ( + Array.isArray(body.scopes) && + body.scopes.every((s) => isToolScope(s)) + ) { + scopes = body.scopes as ToolScope[]; + } else { + return c.json({ detail: 'Invalid scopes.' }, 400); + } + + const { grantId, expiresIn } = createToolGrant({ + user: { + id: user.id, + loggingConsent: user.loggingConsent, + isAnonymous: user.isAnonymous, + isAllowed: user.isAllowed, + }, + toolClientId, + scopes, + docSnapshot: body.doc, + }); + return c.json({ grant_id: grantId, expires_in: expiresIn }); + }); + + // Exchange a grant_id for a bearer token (+ the document snapshot). Unauthenticated + // by design — the grant_id itself is the credential, and the tool has no session + // yet. Single-use and short-lived (see toolGrants.ts). + app.post('/api/handoff/exchange', async (c) => { + const { grant_id } = (await c.req.json().catch(() => ({}))) as { + grant_id?: unknown; + }; + if (typeof grant_id !== 'string' || grant_id === '') { + return c.json({ detail: 'Missing grant_id.' }, 400); + } + + const result = exchangeToolGrant(grant_id); + if (!result.ok) { + // A consumed/expired/unknown grant is all a 400 to the tool — it can only + // restart the flow. The distinct `error` code aids debugging. + return c.json({ detail: 'Grant not exchangeable.', error: result.error }, 400); + } + return c.json({ + access_token: result.accessToken, + token_type: 'bearer', + expires_in: result.expiresIn, + client_id: result.clientId, + scopes: result.scopes, + user: { id: result.user.id, loggingConsent: result.user.loggingConsent }, + doc: result.doc, + }); + }); + + // Re-fetch the document snapshot while the tool's token lives (the plan's optional + // GET /api/handoff/:id/doc). Keyed by the token, gated by the doc:read scope. + app.get('/api/handoff/doc', (c) => { + const token = bearerToken(c); + if (!token || !token.startsWith('wtk_')) { + return c.json({ detail: 'Unauthorized' }, 401); + } + const resolved = resolveToolToken(token); + if (!resolved) return c.json({ detail: 'Unauthorized' }, 401); + if (!resolved.scopes.includes('doc:read')) { + return c.json({ detail: 'Forbidden: doc:read not granted.' }, 403); + } + const snapshot = getToolTokenDoc(token); + // resolveToolToken already proved the token is live; snapshot can't be undefined. + return c.json({ doc: snapshot?.doc ?? null }); + }); + + // Disconnect: a tool (or the sidebar acting for it) revokes its own token. + app.post('/api/handoff/revoke', (c) => { + const token = bearerToken(c); + if (!token || !token.startsWith('wtk_')) { + return c.json({ detail: 'Unauthorized' }, 401); + } + const revoked = revokeToolToken(token); + return c.json({ revoked }); + }); + app.get('/api/ping', (c) => c.json({ timestamp: new Date().toISOString(), gitCommit: gitCommit() }), ); diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 5140de85..b52b0d91 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -180,4 +180,11 @@ export interface SessionUser { * "not allowed" screen by hitting the API directly. */ isAllowed: boolean; + /** + * Which client this request came through: a registered tool's client_id when the + * request carries a tool grant token (toolGrants.ts) or a self-identifying + * `X-Client-Id` header, or null for the first-party add-in. Attribution only — it + * never widens what the identity can do. + */ + clientId: string | null; } diff --git a/backend/src/db.ts b/backend/src/db.ts index 5ec2f03c..3dc4a73c 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -72,6 +72,52 @@ const MIGRATIONS: Array<(conn: Database.Database) => void> = [ CREATE INDEX llm_usage_user_ts ON llm_usage (user_id, ts); `); }, + // v2 — attribute each metered request to the client that made it (the add-in + // itself, or an external writing tool launched from the sidebar). Nullable: rows + // written before this migration, and add-in traffic that doesn't identify itself, + // stay NULL — read as "the first-party add-in". Tool traffic carries the tool's + // registered client_id, giving research provenance ("which tool generated this + // completion") for free. See usage.ts / openaiProxy.ts. + (conn) => { + conn.exec(`ALTER TABLE llm_usage ADD COLUMN client_id TEXT;`); + }, + // v3 — launch grants for the tool launcher (see toolGrants.ts). A sidebar-launched + // tool never touches Office.js or the user's cookies; the taskpane mints a + // single-use, short-TTL grant that the tool exchanges (on its own foreign origin) + // for a bearer token scoped to that tool, plus an optional read-only document + // snapshot. The row is the persistent record; the access token authenticates the + // tool's later calls to the LLM proxy and log endpoints. No foreign key to `user`: + // the token outlives nothing important, but a cascade is needless coupling and the + // grant is validated against a live session at creation time regardless. + (conn) => { + conn.exec(` + CREATE TABLE tool_grant ( + grant_id TEXT PRIMARY KEY, + -- Bearer token handed back on exchange (prefixed 'wtk_'); NULL until then. + access_token TEXT UNIQUE, + -- The full authenticated identity captured at creation time (JSON of the + -- SessionUser minus client_id), so exchange/proxy never depend on the + -- auth user table still holding the row. + user_snapshot TEXT NOT NULL, + -- The tool this grant authorizes — a registered device/tool client_id. + tool_client_id TEXT NOT NULL, + -- JSON array of granted scopes (openai:chat, log:write, doc:read, …). + scopes TEXT NOT NULL, + -- JSON DocContext snapshot handed to the tool, or NULL when none was shared. + doc_snapshot TEXT, + created_at INTEGER NOT NULL, + -- Grant TTL: the window in which the grant_id may be exchanged (~2 min). + expires_at INTEGER NOT NULL, + -- Single-use marker: set to the exchange time; a second exchange is refused. + exchanged_at INTEGER, + -- Access-token validity, set at exchange time. + token_expires_at INTEGER, + -- Disconnect: set when the token is revoked (tool or user teardown). + revoked_at INTEGER + ); + CREATE INDEX tool_grant_user ON tool_grant (json_extract(user_snapshot, '$.id')); + `); + }, ]; function migrate(conn: Database.Database): void { diff --git a/backend/src/logging.ts b/backend/src/logging.ts index f42f8a00..5f94e106 100644 --- a/backend/src/logging.ts +++ b/backend/src/logging.ts @@ -13,6 +13,11 @@ import { dataDir } from './config.js'; * are added by the frontend event-logging layer; entries written before it * existed omit both fields — readers should treat a missing `schema_version` as * 0 and a missing `page` as `null`. + * + * `client_id` is the tool that produced the event — a registered tool's client_id + * when the log line came in over a tool grant token, or null for the first-party + * add-in. It's the logging counterpart of the same column on `llm_usage`, keying + * study events by (user, tool) so a per-study export can scope to its own tool. */ export interface LogEntry { timestamp: number; @@ -21,6 +26,7 @@ export interface LogEntry { event: string; schema_version: number; page: string | null; + client_id: string | null; extra_data: Record; } diff --git a/backend/src/openaiProxy.ts b/backend/src/openaiProxy.ts index 001267a1..b60bb285 100644 --- a/backend/src/openaiProxy.ts +++ b/backend/src/openaiProxy.ts @@ -34,7 +34,10 @@ export type OpenAIEndpoint = 'chat/completions' | 'responses'; * The subset of the session identity the proxy needs to decide who pays — see * SessionUser in auth.ts (the canonical shape). */ -export type ProxyUser = Pick; +export type ProxyUser = Pick< + SessionUser, + 'id' | 'isAnonymous' | 'isAllowed' | 'clientId' +>; export interface ProxyOptions { /** Authenticated user for this request, or null if there's no session. */ @@ -229,6 +232,10 @@ export function openaiProxy(endpoint: OpenAIEndpoint, options: ProxyOptions) { if (user && !user.isAllowed) return c.json({ detail: 'Forbidden' }, 403); const attribution = attributeRequest(user, options.authEnabled); if (!attribution) return c.json({ detail: 'Unauthorized' }, 401); + // Which client the tokens are provenance-tagged to (a tool's client_id, or + // null for the first-party add-in). Independent of who pays: a demo user's + // spend goes to the capped key but the row still names their tool. + const clientId = user?.clientId ?? null; const body = await c.req.text(); const parsedBody = parseBody(body); @@ -268,6 +275,7 @@ export function openaiProxy(endpoint: OpenAIEndpoint, options: ProxyOptions) { try { recordUsage({ userId: attribution.userId, + clientId, provider: 'openai', endpoint, model: model ?? requestedModel(parsedBody), diff --git a/backend/src/toolGrants.ts b/backend/src/toolGrants.ts new file mode 100644 index 00000000..38ead5e3 --- /dev/null +++ b/backend/src/toolGrants.ts @@ -0,0 +1,229 @@ +/** + * Launch grants for the tool launcher (see docs/tool-launcher-plan.md, Phase 1). + * + * A "tool" is an external writing prototype launched from the sidebar — it runs on + * its own foreign origin (researcher-hosted), never touches Office.js, and never + * sees our cookies. To give it access "under the user's account" without an + * interactive login, the taskpane (already signed in) mints a **launch grant**: + * + * 1. taskpane → POST /api/handoff (authenticated): stash { user, tool, scopes, + * optional document snapshot }, get back a single-use grant_id with a short TTL. + * 2. taskpane opens the tool at `https://tool.example/#wt_grant=` — a URL + * *fragment*, so the grant never reaches the tool's server or intermediary logs. + * 3. tool → POST /api/handoff/exchange { grant_id }: swap the grant for a bearer + * token (scoped to that tool) plus the document snapshot. + * + * The bearer token issued here is our own opaque credential, prefixed `wtk_` so + * app.ts's resolveUser can tell it apart from a Better Auth session token and + * resolve it here instead. It carries the tool's client_id, so every LLM + * completion and log line the tool produces is attributed to (user, tool) for free. + * + * We deliberately do NOT mint a Better Auth session for the tool: Better Auth has + * no server-side "create a session for this user" primitive, and a parallel token + * table keeps the tool's reach explicit (scopes, per-token revoke) and keeps this + * whole flow testable without standing up auth. The pure device flow + * (frontend/src/api/deviceAuth.ts) remains the fallback for tools opened directly. + */ +import { randomBytes } from 'node:crypto'; +import type { SessionUser } from './auth.js'; +import { db } from './db.js'; + +/** + * Scopes a tool may request. Recorded and surfaced now (the future consent screen + * and Phase 3 enforcement read them); in this first-party phase the proxy still + * treats a valid tool token as a full session token, except where a scope check is + * the natural gate — the document re-fetch requires `doc:read`. + */ +export const TOOL_SCOPES = [ + 'openai:chat', + 'log:write', + 'doc:read', + 'doc:write', +] as const; +export type ToolScope = (typeof TOOL_SCOPES)[number]; + +/** Scopes granted when a launch request names none. */ +export const DEFAULT_TOOL_SCOPES: ToolScope[] = [ + 'openai:chat', + 'log:write', + 'doc:read', +]; + +export function isToolScope(value: unknown): value is ToolScope { + return ( + typeof value === 'string' && (TOOL_SCOPES as readonly string[]).includes(value) + ); +} + +/** How long a freshly minted grant_id may be exchanged for a token. */ +export const GRANT_TTL_MS = 2 * 60 * 1000; // 2 minutes +/** How long the exchanged bearer token stays valid. */ +export const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour + +/** The identity captured on the grant — the full SessionUser minus its clientId + * (clientId is the grant's own tool_client_id, added back on resolve). */ +type GrantUser = Omit; + +function randomId(prefix: string): string { + return `${prefix}${randomBytes(24).toString('base64url')}`; +} + +export interface CreateGrantInput { + user: GrantUser; + toolClientId: string; + scopes: ToolScope[]; + /** DocContext snapshot to hand the tool, or null. Stored opaquely as JSON. */ + docSnapshot: unknown; +} + +export interface CreatedGrant { + grantId: string; + /** Seconds until the grant_id can no longer be exchanged. */ + expiresIn: number; +} + +/** Stash a launch grant. Called from the authenticated POST /api/handoff route. */ +export function createToolGrant(input: CreateGrantInput): CreatedGrant { + const grantId = randomId('wtg_'); + const now = Date.now(); + const expiresAt = now + GRANT_TTL_MS; + db() + .prepare( + `INSERT INTO tool_grant ( + grant_id, access_token, user_snapshot, tool_client_id, scopes, + doc_snapshot, created_at, expires_at + ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?)`, + ) + .run( + grantId, + JSON.stringify(input.user), + input.toolClientId, + JSON.stringify(input.scopes), + input.docSnapshot === undefined ? null : JSON.stringify(input.docSnapshot), + now, + expiresAt, + ); + return { grantId, expiresIn: Math.floor(GRANT_TTL_MS / 1000) }; +} + +interface GrantRow { + grant_id: string; + access_token: string | null; + user_snapshot: string; + tool_client_id: string; + scopes: string; + doc_snapshot: string | null; + created_at: number; + expires_at: number; + exchanged_at: number | null; + token_expires_at: number | null; + revoked_at: number | null; +} + +export type ExchangeResult = + | { + ok: true; + accessToken: string; + expiresIn: number; + user: GrantUser; + clientId: string; + scopes: ToolScope[]; + doc: unknown; + } + | { ok: false; error: 'not_found' | 'expired' | 'already_used' }; + +/** + * Swap a grant_id for a bearer token. Single-use: the first successful exchange + * mints the token and marks the grant; any later attempt is refused. Called from + * the unauthenticated POST /api/handoff/exchange route — the grant_id itself is the + * credential, so no session is required (and the tool doesn't have one yet). + */ +export function exchangeToolGrant(grantId: string): ExchangeResult { + const conn = db(); + // Serialize the read-decide-write so two racing exchanges can't both succeed. + const tx = conn.transaction((): ExchangeResult => { + const row = conn + .prepare(`SELECT * FROM tool_grant WHERE grant_id = ?`) + .get(grantId) as GrantRow | undefined; + if (!row) return { ok: false, error: 'not_found' }; + if (row.exchanged_at !== null) return { ok: false, error: 'already_used' }; + if (Date.now() > row.expires_at) return { ok: false, error: 'expired' }; + + const now = Date.now(); + const accessToken = randomId('wtk_'); + const tokenExpiresAt = now + TOKEN_TTL_MS; + conn + .prepare( + `UPDATE tool_grant + SET access_token = ?, exchanged_at = ?, token_expires_at = ? + WHERE grant_id = ?`, + ) + .run(accessToken, now, tokenExpiresAt, grantId); + + return { + ok: true, + accessToken, + expiresIn: Math.floor(TOKEN_TTL_MS / 1000), + user: JSON.parse(row.user_snapshot) as GrantUser, + clientId: row.tool_client_id, + scopes: JSON.parse(row.scopes) as ToolScope[], + doc: row.doc_snapshot === null ? null : JSON.parse(row.doc_snapshot), + }; + }); + return tx(); +} + +/** A live token's row, or null when the token is unknown/expired/revoked. */ +function liveTokenRow(token: string): GrantRow | null { + const row = db() + .prepare(`SELECT * FROM tool_grant WHERE access_token = ?`) + .get(token) as GrantRow | undefined; + if (!row || row.revoked_at !== null) return null; + if (row.token_expires_at !== null && Date.now() > row.token_expires_at) { + return null; + } + return row; +} + +export interface ResolvedToolToken { + user: SessionUser; + scopes: ToolScope[]; +} + +/** + * Resolve a `wtk_` bearer token to the identity behind it, with clientId set to the + * tool the grant was issued for. Returns null for any unknown, expired, or revoked + * token. This is what lets a tool's requests flow through the LLM proxy and log + * endpoints under the user's account. + */ +export function resolveToolToken(token: string): ResolvedToolToken | null { + const row = liveTokenRow(token); + if (!row) return null; + const user = JSON.parse(row.user_snapshot) as GrantUser; + return { + user: { ...user, clientId: row.tool_client_id }, + scopes: JSON.parse(row.scopes) as ToolScope[], + }; +} + +/** + * The document snapshot behind a live token, for the optional re-fetch endpoint. + * Returns null (no snapshot) vs. undefined (invalid token) so the caller can 401 + * on the latter. + */ +export function getToolTokenDoc(token: string): { doc: unknown } | undefined { + const row = liveTokenRow(token); + if (!row) return undefined; + return { doc: row.doc_snapshot === null ? null : JSON.parse(row.doc_snapshot) }; +} + +/** Disconnect a tool: revoke its token. Returns false if the token was unknown. */ +export function revokeToolToken(token: string): boolean { + const info = db() + .prepare( + `UPDATE tool_grant SET revoked_at = ? + WHERE access_token = ? AND revoked_at IS NULL`, + ) + .run(Date.now(), token); + return info.changes > 0; +} diff --git a/backend/src/usage.ts b/backend/src/usage.ts index de2119d3..444af721 100644 --- a/backend/src/usage.ts +++ b/backend/src/usage.ts @@ -43,6 +43,12 @@ export const ANONYMOUS_USER_ID = 'anonymous'; */ export interface UsageRecord { userId: string; + /** + * Which client made the request: a registered tool's client_id, or null for the + * first-party add-in (which doesn't need to name itself). Pure provenance — the + * paying identity is still `userId`. + */ + clientId: string | null; provider: string; endpoint: string; model: string; @@ -61,14 +67,15 @@ export function recordUsage(record: UsageRecord): void { db() .prepare( `INSERT INTO llm_usage ( - ts, user_id, provider, endpoint, model, + ts, user_id, client_id, provider, endpoint, model, input_tokens, cached_input_tokens, output_tokens, reasoning_tokens, status, streamed, duration_ms, ttft_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( Date.now(), record.userId, + record.clientId, record.provider, record.endpoint, record.model, @@ -102,6 +109,8 @@ export function anonymizeUserUsage(userId: string): void { export interface UsageSummaryRow { userId: string; email: string | null; + /** Registered tool client_id, or null for first-party add-in traffic. */ + clientId: string | null; provider: string; model: string; requests: number; @@ -123,7 +132,7 @@ export function summarizeUsage( ): UsageSummaryRow[] { const rows = db() .prepare( - `SELECT user_id, provider, model, + `SELECT user_id, client_id, provider, model, COUNT(*) AS requests, SUM(input_tokens) AS input_tokens, SUM(cached_input_tokens) AS cached_input_tokens, @@ -131,15 +140,16 @@ export function summarizeUsage( SUM(reasoning_tokens) AS reasoning_tokens FROM llm_usage WHERE ts >= ? AND ts < ? - GROUP BY user_id, provider, model - ORDER BY user_id, provider, model`, + GROUP BY user_id, client_id, provider, model + ORDER BY user_id, client_id, provider, model`, ) - .all(since, until) as Array>; + .all(since, until) as Array>; const emails = userEmails(); return rows.map((r) => ({ userId: String(r.user_id), email: emails.get(String(r.user_id)) ?? null, + clientId: r.client_id == null ? null : String(r.client_id), provider: String(r.provider), model: String(r.model), requests: Number(r.requests), diff --git a/docs/tool-launcher-plan.md b/docs/tool-launcher-plan.md index 58adccda..e3544427 100644 --- a/docs/tool-launcher-plan.md +++ b/docs/tool-launcher-plan.md @@ -5,9 +5,30 @@ writing tools (first-party prototypes now, community research tools later), without merging them into the add-in or supporting them as first-class features. -> **Status:** Exploration/proposal. Nothing here is implemented. Depends on -> the Better Auth integration described in `auth-plan.md` actually being -> enforced on the proxy routes (it currently is not). +> **Status:** Phases 0 and 1 are implemented; Phases 2+ remain proposal. +> Implemented so far: +> - **Phase 0** — the proxy and `/api/log` already require a Bearer session and +> fail closed for sessionless traffic (this predated the launcher work). On top +> of that, every metered request and log line now carries a `client_id` +> attributing it to `(user, tool)`: external tools via their grant token, the +> first-party add-in via an allowlisted `X-Client-Id` header, null otherwise. +> See `backend/src/openaiProxy.ts`, `usage.ts` (`llm_usage.client_id`, db v2), +> `logging.ts`, and `app.ts` `resolveUser`. +> - **Phase 1** — the handoff grant flow (`backend/src/toolGrants.ts`, `tool_grant` +> table db v3; routes `POST /api/handoff`, `/api/handoff/exchange`, +> `GET /api/handoff/doc`, `POST /api/handoff/revoke`) and a "Tools" page in the +> sidebar (`frontend/src/pages/tools/`, `PageName.Tools`) that launches a +> registered tool in the browser with a token + read-only doc snapshot, plus a +> paste-a-URL field for device-flow tools. One deviation from the sketch below: +> the tool's bearer token is our own opaque `wtk_` credential (a `tool_grant` +> row), not a Better Auth session — Better Auth has no server-side session-mint +> primitive, and a parallel token keeps the tool's scopes and per-token revoke +> explicit. Revocation is per-token, not Better Auth session revocation. +> +> Everything below Phase 1 (rooms, panel tools, scoped tokens/quotas/manifests) is +> still exploration. Scope enforcement on tool tokens is deferred to Phase 3: a +> valid tool token is currently treated as a full session token, except the doc +> re-fetch, which requires `doc:read`. ## Motivating examples @@ -115,18 +136,16 @@ normal session schedule; a "Disconnect" row per tool in the sidebar calls Better Auth session revocation. Nothing needs the taskpane to stay open after launch *unless* a live document channel is granted (below). -### Prerequisite: the proxy must actually check tokens +### Prerequisite: the proxy must actually check tokens — done -Today `/api/openai/chat/completions` and `/api/log` are unauthenticated -(`auth-plan.md` documents this). "Proxy access under their account" is -meaningless until the backend: - -- requires a valid Bearer session on `/api/openai/*` and `/api/log`; -- attributes each call to `(user, client_id)` — which also gives research - provenance ("which tool generated this completion") for free; -- (later) enforces per-user/per-tool quotas before third parties arrive. - -This is the first implementation step regardless of everything else. +`/api/openai/*` and `/api/log` require a valid Bearer session and fail closed for +sessionless traffic (`app.ts` `resolveUser`, `openaiProxy.ts` `attributeRequest`). +Each call is attributed to `(user, client_id)`: the `client_id` is the tool's id +when the request carries a `wtk_` grant token, an allowlisted `X-Client-Id` header +otherwise (the add-in stamps its own), and null for unlabelled first-party traffic. +That gives research provenance ("which tool generated this completion") for free — +it's a column on both `llm_usage` and the JSONL log envelope. Per-user/per-tool +quotas remain deferred to Phase 3. ## Document access: brokered, snapshot-first @@ -281,13 +300,14 @@ origins. ## Phasing -1. **Phase 0 (prereq):** enable Better Auth in production per - `auth-plan.md`; require Bearer on `/api/openai/*` and `/api/log`; - attribute logs/usage to the session user. -2. **Phase 1:** "Tools" page in the sidebar; hardcoded tool list + URL - field; browser launch with handoff grant (token + read-only doc - snapshot); mindmap ported to consume it. Device-flow fallback for - direct visits. +1. **Phase 0 (prereq) — done:** Bearer required on `/api/openai/*` and + `/api/log`, failing closed for sessionless traffic; usage and logs attributed + to the session user *and* to a `client_id` (add-in vs. tool). See the Status + note above. +2. **Phase 1 — done:** "Tools" page in the sidebar; hardcoded tool list + URL + field; browser launch with handoff grant (token + read-only doc snapshot); + device-flow fallback for direct visits. (Porting the mindmap to actually + consume the grant is tracked separately — it lives on `feat/uist`.) 3. **Phase 2:** rooms — WebSocket switchboard with membership + scopes (`doc:read` / `doc:write`, patch-with-confirm writes), `rpc` / `broadcast` / `presence` message types, room list + QR/short-URL join. diff --git a/frontend/src/api/__tests__/handoff.test.ts b/frontend/src/api/__tests__/handoff.test.ts new file mode 100644 index 00000000..37ec2f11 --- /dev/null +++ b/frontend/src/api/__tests__/handoff.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Keep the helper off the real ./index (which pulls in the Office/Google editor +// APIs); only SERVER_URL is needed. +vi.mock('../index', () => ({ SERVER_URL: '/api' })); + +import { createHandoff, openInBrowser, withGrantFragment } from '../handoff'; + +type Json = Record; +const resp = (data: Json, ok = true, status = 200) => + ({ ok, status, json: () => Promise.resolve(data) }) as Response; + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('withGrantFragment', () => { + it('adds wt_grant to a URL with no fragment', () => { + expect(withGrantFragment('https://tool.example/', 'g1')).toBe( + 'https://tool.example/#wt_grant=g1', + ); + }); + + it('appends to an existing fragment instead of clobbering it', () => { + expect(withGrantFragment('https://tool.example/#/route', 'g1')).toBe( + 'https://tool.example/#/route&wt_grant=g1', + ); + }); + + it('url-encodes the grant id', () => { + expect(withGrantFragment('https://tool.example/', 'a b')).toContain( + 'wt_grant=a%20b', + ); + }); +}); + +describe('createHandoff', () => { + it('posts the request bearer-only and returns the grant', async () => { + fetchMock.mockResolvedValue( + resp({ grant_id: 'wtg_1', expires_in: 120 }), + ); + + const out = await createHandoff('tok', { + toolClientId: 'mindmap', + scopes: ['openai:chat', 'doc:read'], + doc: { beforeCursor: 'x', selectedText: '', afterCursor: '' }, + }); + + expect(out).toEqual({ grantId: 'wtg_1', expiresIn: 120 }); + const [url, init] = fetchMock.mock.calls[0] as [ + string, + { + credentials: string; + headers: Record; + body: string; + }, + ]; + expect(url).toBe('/api/handoff'); + expect(init.credentials).toBe('omit'); + expect(init.headers.Authorization).toBe('Bearer tok'); + const body = JSON.parse(init.body) as { + tool_client_id: string; + scopes: string[]; + doc: { beforeCursor: string }; + }; + expect(body.tool_client_id).toBe('mindmap'); + expect(body.scopes).toEqual(['openai:chat', 'doc:read']); + expect(body.doc.beforeCursor).toBe('x'); + }); + + it('throws the server detail on failure', async () => { + fetchMock.mockResolvedValue( + resp({ detail: 'Unknown tool_client_id.' }, false, 400), + ); + await expect( + createHandoff('tok', { toolClientId: 'nope' }), + ).rejects.toThrow('Unknown tool_client_id.'); + }); +}); + +describe('openInBrowser', () => { + it('prefers the Office system-browser bridge in the task pane', () => { + const openBrowserWindow = vi.fn(); + vi.stubGlobal('Office', { context: { ui: { openBrowserWindow } } }); + const windowOpen = vi.fn(); + vi.stubGlobal('window', { open: windowOpen }); + + openInBrowser('https://tool.example/#wt_grant=g1'); + + expect(openBrowserWindow).toHaveBeenCalledWith( + 'https://tool.example/#wt_grant=g1', + ); + expect(windowOpen).not.toHaveBeenCalled(); + }); + + it('falls back to window.open when Office is absent', () => { + vi.stubGlobal('Office', undefined); + const windowOpen = vi.fn(); + vi.stubGlobal('window', { open: windowOpen }); + + openInBrowser('https://tool.example/'); + + expect(windowOpen).toHaveBeenCalledWith( + 'https://tool.example/', + '_blank', + 'noopener,noreferrer', + ); + }); +}); diff --git a/frontend/src/api/handoff.ts b/frontend/src/api/handoff.ts new file mode 100644 index 00000000..398a6b01 --- /dev/null +++ b/frontend/src/api/handoff.ts @@ -0,0 +1,86 @@ +/** + * Tool launcher — client side of the handoff grant flow (see + * docs/tool-launcher-plan.md, Phase 1; backend/src/toolGrants.ts). + * + * The taskpane is the only surface that can read the Office document and holds a + * signed-in session, so it mints the launch grant on the tool's behalf: it posts + * the chosen tool + scopes (and an optional document snapshot) to /api/handoff, gets + * back a single-use grant_id, and opens the tool at `…#wt_grant=`. The + * grant travels in the URL *fragment*, which browsers never send to the server or + * write to intermediary access logs, so the credential doesn't leak in transit. + */ +import { SERVER_URL } from './index'; + +export type ToolScope = 'openai:chat' | 'log:write' | 'doc:read' | 'doc:write'; + +export interface HandoffRequest { + /** A tool client_id registered in the backend device allowlist. */ + toolClientId: string; + /** Scopes to grant; the backend defaults a read-only set when omitted. */ + scopes?: ToolScope[]; + /** Read-only document snapshot to hand the tool, or omit to share none. */ + doc?: DocContext; +} + +/** + * Create a launch grant. `token` is the current session bearer (from + * `useAppAuth().getAccessToken()`); the request is credential-omitting and + * bearer-only, matching the rest of the authenticated client surface. + */ +export async function createHandoff( + token: string, + req: HandoffRequest, +): Promise<{ grantId: string; expiresIn: number }> { + const res = await fetch(`${SERVER_URL}/handoff`, { + method: 'POST', + credentials: 'omit', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + tool_client_id: req.toolClientId, + scopes: req.scopes, + doc: req.doc, + }), + }); + if (!res.ok) { + const detail = await res + .json() + .then((b: { detail?: string }) => b.detail) + .catch(() => undefined); + throw new Error(detail ?? `Handoff failed (${res.status})`); + } + const body = (await res.json()) as { grant_id: string; expires_in: number }; + return { grantId: body.grant_id, expiresIn: body.expires_in }; +} + +/** Append `wt_grant=` to a URL's fragment without clobbering an existing hash. */ +export function withGrantFragment(url: string, grantId: string): string { + const u = new URL(url); + const existing = u.hash.replace(/^#/, ''); + const param = `wt_grant=${encodeURIComponent(grantId)}`; + u.hash = existing ? `${existing}&${param}` : param; + return u.toString(); +} + +/** + * Open a URL in the user's real browser. In the Word task pane + * `Office.context.ui.openBrowserWindow` hands off to the system browser (a full-page + * tool needs a full page); everywhere else a normal new tab. `noopener` severs the + * `window.opener` back-reference so the tool can't script the launching context. + */ +export function openInBrowser(url: string): void { + const officeUi = ( + globalThis as { + Office?: { + context?: { ui?: { openBrowserWindow?: (u: string) => void } }; + }; + } + ).Office?.context?.ui; + if (officeUi?.openBrowserWindow) { + officeUi.openBrowserWindow(url); + return; + } + window.open(url, '_blank', 'noopener,noreferrer'); +} diff --git a/frontend/src/api/logging.ts b/frontend/src/api/logging.ts index 8752e214..3edac6ea 100644 --- a/frontend/src/api/logging.ts +++ b/frontend/src/api/logging.ts @@ -36,11 +36,12 @@ import type { LogFn } from '@/hooks/useLog'; * * History: * 1 — Initial page-scoped schema (Draft / Revise / Chat). + * 2 — Added the Tools page (external tool launcher) and its events. */ -export const LOG_SCHEMA_VERSION = 1; +export const LOG_SCHEMA_VERSION = 2; /** Pages that emit events. Matches the user-facing tabs. */ -export type LogPage = 'draft' | 'revise' | 'chat'; +export type LogPage = 'draft' | 'revise' | 'chat' | 'tools'; /** * Emit one event through the page's {@link LogFn}, stamping the schema version, @@ -70,14 +71,22 @@ export const draftLog = { /** A generated suggestion was shown to (and saved for) the writer. */ suggestionShown( log: LogFn, - data: { generationType: string; docContext: DocContext; result: GenerationResult }, + data: { + generationType: string; + docContext: DocContext; + result: GenerationResult; + }, ) { return emit(log, 'draft', 'suggestion_shown', data); }, /** The writer deleted a saved suggestion. */ suggestionDeleted( log: LogFn, - data: { generationType: string; docContext: DocContext; result: GenerationResult }, + data: { + generationType: string; + docContext: DocContext; + result: GenerationResult; + }, ) { return emit(log, 'draft', 'suggestion_deleted', data); }, @@ -161,3 +170,26 @@ export const chatLog = { return emit(log, 'chat', 'response_error', data); }, }; + +/** + * Tools page: the writer launches an external writing tool from the sidebar. The + * document snapshot itself is never logged here (only whether one was shared); the + * tool's own events are attributed to it server-side via its client_id. + */ +export const toolsLog = { + /** The writer launched a registered first-party tool via a handoff grant. */ + toolLaunched( + log: LogFn, + data: { tool: string; sharedDoc: boolean; scopes: string[] }, + ) { + return emit(log, 'tools', 'tool_launched', data); + }, + /** A handoff grant could not be minted (launch aborted). */ + launchError(log: LogFn, data: { tool: string; error: string }) { + return emit(log, 'tools', 'launch_error', data); + }, + /** The writer opened an ad-hoc pasted URL directly (no grant; device-flow tool). */ + adhocOpened(log: LogFn) { + return emit(log, 'tools', 'adhoc_opened', {}); + }, +}; diff --git a/frontend/src/api/openai.ts b/frontend/src/api/openai.ts index 594c6ba0..3420e680 100644 --- a/frontend/src/api/openai.ts +++ b/frontend/src/api/openai.ts @@ -2,6 +2,7 @@ import { createOpenAI, type OpenAIResponsesProviderOptions, } from '@ai-sdk/openai'; +import { DEVICE_CLIENT_ID } from './deviceAuth'; import { SERVER_URL } from './index'; /** @@ -27,6 +28,10 @@ async function authorizedFetch( init?: RequestInit, ): Promise { const headers = new Headers(init?.headers); + // Identify this traffic as the first-party add-in so the proxy attributes its + // usage to (user, add-in). External tools send their own client_id via a tool + // grant token; the backend honours this header only for allowlisted ids. + headers.set('X-Client-Id', DEVICE_CLIENT_ID); try { const token = await tokenProvider?.(); // Overwrites the placeholder `apiKey` Bearer header the SDK sets. diff --git a/frontend/src/contexts/pageContext.tsx b/frontend/src/contexts/pageContext.tsx index 0c9f902e..7c639ffd 100644 --- a/frontend/src/contexts/pageContext.tsx +++ b/frontend/src/contexts/pageContext.tsx @@ -5,6 +5,7 @@ export enum PageName { Chat = 'chat', Draft = 'draft', TagLinker = 'tag-linker', + Tools = 'tools', } export enum OverallMode { diff --git a/frontend/src/hooks/useLog.ts b/frontend/src/hooks/useLog.ts index 013f2a89..a68d535f 100644 --- a/frontend/src/hooks/useLog.ts +++ b/frontend/src/hooks/useLog.ts @@ -13,6 +13,7 @@ */ import { useCallback } from 'react'; import { SERVER_URL } from '@/api'; +import { DEVICE_CLIENT_ID } from '@/api/deviceAuth'; import { filterPayloadForConsent } from '@/consent'; import { useAppAuth } from '@/contexts/appAuthContext'; @@ -50,6 +51,8 @@ export function useLog(): LogFn { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, + // Attribute first-party add-in events to (user, add-in). + 'X-Client-Id': DEVICE_CLIENT_ID, }, body: JSON.stringify({ ...filtered, diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index 067b5101..e2321c0d 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -127,7 +127,13 @@ function DeviceAuthStatus({ {authorization.verificationUri ? (

- Open approval page + + Open approval page +

) : null}

Open the approval page and enter the code above to continue.

@@ -140,8 +146,14 @@ function AppInner() { const mode = useAtomValue(overallModeAtom); const noAuthMode = mode !== OverallMode.full; const session = useAppAuth(); - const { isLoading, isAuthorizing, error, isAuthenticated, user, authorization } = - session; + const { + isLoading, + isAuthorizing, + error, + isAuthenticated, + user, + authorization, + } = session; const [width, _height] = useWindowSize(); const page = useAtomValue(pageNameAtom); const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(() => { @@ -466,7 +478,8 @@ function PostHogConsentBridge(): null { useEffect(() => { if (!posthog) return; const analyticsAllowed = - isAuthenticated && consentRank(loggingConsent) >= consentRank('usage'); + isAuthenticated && + consentRank(loggingConsent) >= consentRank('usage'); // Require a stable id before opting in, so we never capture untethered // anonymous events that can't be tied to a deletable account identity. diff --git a/frontend/src/pages/registry.tsx b/frontend/src/pages/registry.tsx index 0ac8e4d3..048eb2e7 100644 --- a/frontend/src/pages/registry.tsx +++ b/frontend/src/pages/registry.tsx @@ -31,7 +31,9 @@ import type React from 'react'; import { PageName } from '@/contexts/pageContext'; import Chat from './chat'; import Draft from './draft'; +import { isFlagEnabled } from './flags'; import Revise from './revise'; +import Tools from './tools'; export type PageTier = 'core' | 'lab'; @@ -80,6 +82,14 @@ export const PAGES: PageDef[] = [ tier: 'core', render: () => , }, + { + name: PageName.Tools, + title: 'Tools', + hint: 'Launch writing tools', + tier: 'lab', + enabled: () => isFlagEnabled('tool-launcher'), + render: () => , + }, ]; /** The page shown when nothing is selected, or when the selection isn't visible. */ diff --git a/frontend/src/pages/tools/index.tsx b/frontend/src/pages/tools/index.tsx new file mode 100644 index 00000000..9b3d7b9d --- /dev/null +++ b/frontend/src/pages/tools/index.tsx @@ -0,0 +1,192 @@ +/** + * Tools page — the sidebar's launch point for external writing tools + * (docs/tool-launcher-plan.md, Phase 1). + * + * A registered first-party tool runs full-page on its own origin, not inside the + * ~350px taskpane. Launching one mints a handoff grant (a bearer token scoped to + * that tool, plus an optional read-only document snapshot) and opens the tool in the + * user's real browser at `…#wt_grant=`; the tool exchanges the grant for its + * token. The paste-a-URL field is for ad-hoc launches — it opens the URL directly, + * and such a tool signs in for itself via the device flow if it needs access. + */ +import { useContext, useState } from 'react'; +import { Button } from 'reshaped'; +import { + createHandoff, + openInBrowser, + type ToolScope, + withGrantFragment, +} from '@/api/handoff'; +import { toolsLog } from '@/api/logging'; +import { EditorContext } from '@/contexts/editorContext'; +import { useAppAuth } from '@/contexts/appAuthContext'; +import { useLog } from '@/hooks/useLog'; +import classes from './styles.module.css'; + +interface FirstPartyTool { + /** Tool client_id — must also be listed in the backend device allowlist. */ + id: string; + name: string; + description: string; + /** Where the tool is hosted (its own origin). */ + url: string; + scopes: ToolScope[]; +} + +/** + * Hardcoded first-party tools (Phase 1). Each id must be registered in the + * backend's BETTER_AUTH_DEVICE_CLIENT_IDS allowlist, and the URL points at wherever + * the tool is hosted. A manifest-driven registry replaces this list in a later phase. + */ +const FIRST_PARTY_TOOLS: FirstPartyTool[] = [ + { + // Throwaway tool for exercising the handoff flow end-to-end in dev. + // Served from sandbox/test-tool/ (see that dir's README). Remove before merge. + id: 'test-tool', + name: 'Platform API test tool', + description: + 'Dev-only. Exchanges the handoff grant and lets you fire each Platform API v0 call (LLM proxy, log, doc re-fetch, revoke).', + url: 'http://localhost:4000/', + scopes: ['openai:chat', 'log:write', 'doc:read'], + }, + { + id: 'mindmap', + name: 'Mindmap', + description: + 'Explore your draft as a client-side mindmap. Opens in your browser with a read-only snapshot of your current document.', + url: 'https://mindmap.thoughtful-ai.com/', + scopes: ['openai:chat', 'log:write', 'doc:read'], + }, +]; + +const SCOPE_LABELS: Record = { + 'openai:chat': 'AI access', + 'log:write': 'study logging', + 'doc:read': 'read your document', + 'doc:write': 'edit your document', +}; + +function isLaunchableUrl(raw: string): boolean { + try { + const u = new URL(raw); + return u.protocol === 'https:' || u.protocol === 'http:'; + } catch { + return false; + } +} + +export default function Tools() { + const { getAccessToken } = useAppAuth(); + const editorAPI = useContext(EditorContext); + const log = useLog(); + + const [busyToolId, setBusyToolId] = useState(null); + const [error, setError] = useState(null); + const [adhocUrl, setAdhocUrl] = useState(''); + + async function launch(tool: FirstPartyTool): Promise { + setError(null); + setBusyToolId(tool.id); + try { + const token = await getAccessToken(); + // Only read the document when the tool asked for it — the snapshot leaves + // our trust boundary, so we don't gather it speculatively. + const doc = tool.scopes.includes('doc:read') + ? await editorAPI.getDocContext() + : undefined; + const { grantId } = await createHandoff(token, { + toolClientId: tool.id, + scopes: tool.scopes, + doc, + }); + openInBrowser(withGrantFragment(tool.url, grantId)); + void toolsLog.toolLaunched(log, { + tool: tool.id, + sharedDoc: doc !== undefined, + scopes: tool.scopes, + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setError(`Couldn't launch ${tool.name}: ${message}`); + void toolsLog.launchError(log, { tool: tool.id, error: message }); + } finally { + setBusyToolId(null); + } + } + + function launchAdhoc(): void { + setError(null); + if (!isLaunchableUrl(adhocUrl)) { + setError('Enter a valid http(s) URL.'); + return; + } + openInBrowser(adhocUrl); + void toolsLog.adhocOpened(log); + } + + return ( +
+

+ Launch a writing tool in your browser. It opens under your + account, and gets only what its listed access covers. +

+ + {error ?
{error}
: null} + +
    + {FIRST_PARTY_TOOLS.map((tool) => ( +
  • +
    + + {tool.name} + +
    +

    {tool.description}

    +
    + Wants:{' '} + {tool.scopes.map((s) => SCOPE_LABELS[s]).join(', ')} +
    + +
  • + ))} +
+ +
+ +

+ Opens directly in your browser. The tool signs you in itself + (device flow); no document is shared. +

+
+ setAdhocUrl(e.target.value)} + /> + +
+
+
+ ); +} diff --git a/frontend/src/pages/tools/styles.module.css b/frontend/src/pages/tools/styles.module.css new file mode 100644 index 00000000..93ad4451 --- /dev/null +++ b/frontend/src/pages/tools/styles.module.css @@ -0,0 +1,99 @@ +.container { + padding: 0.75rem 1rem 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.intro { + margin: 0; + font-size: 0.9rem; + color: var(--rs-color-foreground-neutral-faded, #555); + line-height: 1.4; +} + +.error { + background: #fdecea; + color: #b71c1c; + border: 1px solid #f5c6cb; + border-radius: 6px; + padding: 0.5rem 0.75rem; + font-size: 0.85rem; +} + +.toolList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tool { + border: 1px solid var(--rs-color-border-neutral, #e0e0e0); + border-radius: 8px; + padding: 0.85rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.toolHeader { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.toolName { + font-weight: 600; + font-size: 1rem; +} + +.toolDesc { + margin: 0; + font-size: 0.85rem; + line-height: 1.4; + color: var(--rs-color-foreground-neutral-faded, #555); +} + +.scopes { + font-size: 0.78rem; + color: var(--rs-color-foreground-neutral-faded, #777); + margin-bottom: 0.25rem; +} + +.adhoc { + border-top: 1px solid var(--rs-color-border-neutral-faded, #eee); + padding-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.adhocLabel { + font-weight: 600; + font-size: 0.9rem; +} + +.adhocHint { + margin: 0; + font-size: 0.78rem; + color: var(--rs-color-foreground-neutral-faded, #777); + line-height: 1.4; +} + +.adhocRow { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.adhocInput { + flex: 1; + min-width: 0; + padding: 0.45rem 0.6rem; + border: 1px solid var(--rs-color-border-neutral, #ccc); + border-radius: 6px; + font-size: 0.85rem; +} diff --git a/sandbox/test-tool/README.md b/sandbox/test-tool/README.md new file mode 100644 index 00000000..748c2f1d --- /dev/null +++ b/sandbox/test-tool/README.md @@ -0,0 +1,46 @@ +# Platform API v0 — test tool + +A throwaway external "writing tool" for exercising the Phase 1 handoff grant flow +end-to-end (`docs/tool-launcher-plan.md`). It runs on its own origin, exchanges the +launch grant from the URL fragment, and fires each Platform API v0 call. + +**Dev only — not for merge.** It's wired into two places you'll want to revert: + +- `frontend/src/pages/tools/index.tsx` — a `test-tool` entry in `FIRST_PARTY_TOOLS`. +- `backend/.env` — `test-tool` in `BETTER_AUTH_DEVICE_CLIENT_IDS`. + +## Run it + +1. **Backend** (`backend/`): `npm run dev` (port 8000). It needs a full env for the + LLM call to actually work — `OPENAI_API_KEY` (or `OPENAI_DEMO_API_KEY`) and the + Better Auth vars. Run `python scripts/get_env.py` if your `.env` is bare, then + re-add the `BETTER_AUTH_DEVICE_CLIENT_IDS` line if get_env overwrites it. + +2. **Serve this tool** on its own origin (any port other than the backend's): + + ``` + cd sandbox/test-tool && python3 -m http.server 4000 + ``` + + The `url` in `FIRST_PARTY_TOOLS` points at `http://localhost:4000/`. + +3. **Sidebar**: open the standalone editor (`frontend/`: `npm run dev-server`, + then `https://localhost:3000/editor.html`) or the Word taskpane, sign in, go to + the **Tools** page, and click **Launch** on "Platform API test tool". + +The tool opens in your browser at `http://localhost:4000/#wt_grant=`, auto-exchanges +the grant, and shows the token + doc snapshot. The buttons then hit the LLM proxy, +`/api/log`, `/api/handoff/doc`, and `/api/handoff/revoke` with the `wtk_` token. + +If the tool's API-base field (defaulting to `http://localhost:8000`) doesn't match +where your backend runs, edit it and re-exchange. + +## What each button proves + +| Button | Endpoint | Proves | +| --- | --- | --- | +| (auto) Exchange | `POST /api/handoff/exchange` | grant → `wtk_` token + doc snapshot | +| Call LLM proxy | `POST /api/openai/chat/completions` | token authorizes metered LLM access, attributed to `test-tool` | +| Write a log line | `POST /api/log` | JSONL log keyed to `(user, test-tool)` | +| Re-fetch document | `GET /api/handoff/doc` | `doc:read`-gated snapshot re-fetch | +| Revoke token | `POST /api/handoff/revoke` | teardown — subsequent calls 401 | diff --git a/sandbox/test-tool/index.html b/sandbox/test-tool/index.html new file mode 100644 index 00000000..7cef5cdb --- /dev/null +++ b/sandbox/test-tool/index.html @@ -0,0 +1,302 @@ + + + + + + Platform API v0 — Test Tool + + + +

Platform API v0 — Test Tool

+

+ A throwaway external tool that exercises the Phase 1 handoff grant + flow: exchange the launch grant, then use the token against the LLM + proxy, the log endpoint, the doc re-fetch, and revoke. +

+ +
+ 1. Backend + +
+ + +
+
+ +
+ 2. Exchange the grant +

+ Grant from URL fragment: + (none — open me via the sidebar's Launch button) +

+ +

Waiting.

+

+		
+ +
+ 3. Use the token (Platform API v0) + + + + +

+

+		
+ + + +