From b20de99896e00b44b039cd9d741004d2a50c3d6b Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 20 Jul 2026 22:01:27 +0000
Subject: [PATCH 1/4] Add tool launcher: handoff grant flow + per-tool
attribution
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements Phases 0 and 1 of docs/tool-launcher-plan.md.
Phase 0 — attribute every metered request and study log line to
(user, client_id): a registered tool's id via its grant token, an
allowlisted X-Client-Id header otherwise (the add-in stamps its own),
null for unlabelled first-party traffic. Adds llm_usage.client_id
(db v2) and client_id on the JSONL log envelope.
Phase 1 — handoff grant flow (toolGrants.ts, tool_grant table db v3):
the signed-in taskpane mints a single-use, short-TTL grant carrying the
user, tool, scopes, and an optional read-only document snapshot; the
tool swaps it for an opaque wtk_ bearer token that resolveUser resolves
locally, so the tool's proxy/log calls run under the user's account.
Routes: POST /api/handoff, /api/handoff/exchange, GET /api/handoff/doc
(gated by doc:read), POST /api/handoff/revoke. Adds a Tools page to the
sidebar that launches a registered tool in the browser with a doc
snapshot, plus a paste-a-URL field for device-flow tools.
The tool token is our own credential rather than a Better Auth session
(no server-side session-mint primitive; explicit scopes + per-token
revoke). Scope enforcement beyond the doc re-fetch is deferred.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_012HPRT4mayaXBbyvXHmQy1x
---
backend/CLAUDE.md | 18 +-
backend/src/__tests__/db.test.ts | 20 +-
backend/src/__tests__/erasure.test.ts | 2 +
backend/src/__tests__/handoff.test.ts | 209 +++++++++++++++++++
backend/src/__tests__/logging.test.ts | 4 +
backend/src/__tests__/openaiProxy.test.ts | 2 +
backend/src/__tests__/toolGrants.test.ts | 143 +++++++++++++
backend/src/__tests__/usage.test.ts | 1 +
backend/src/app.ts | 156 +++++++++++++-
backend/src/auth.ts | 7 +
backend/src/db.ts | 46 +++++
backend/src/logging.ts | 6 +
backend/src/openaiProxy.ts | 10 +-
backend/src/toolGrants.ts | 229 +++++++++++++++++++++
backend/src/usage.ts | 22 +-
docs/tool-launcher-plan.md | 62 ++++--
frontend/src/api/__tests__/handoff.test.ts | 117 +++++++++++
frontend/src/api/handoff.ts | 86 ++++++++
frontend/src/api/logging.ts | 40 +++-
frontend/src/api/openai.ts | 5 +
frontend/src/components/navbar/index.tsx | 8 +-
frontend/src/contexts/pageContext.tsx | 1 +
frontend/src/hooks/useLog.ts | 8 +-
frontend/src/pages/app/index.tsx | 24 ++-
frontend/src/pages/tools/index.tsx | 182 ++++++++++++++++
frontend/src/pages/tools/styles.module.css | 99 +++++++++
26 files changed, 1459 insertions(+), 48 deletions(-)
create mode 100644 backend/src/__tests__/handoff.test.ts
create mode 100644 backend/src/__tests__/toolGrants.test.ts
create mode 100644 backend/src/toolGrants.ts
create mode 100644 frontend/src/api/__tests__/handoff.test.ts
create mode 100644 frontend/src/api/handoff.ts
create mode 100644 frontend/src/pages/tools/index.tsx
create mode 100644 frontend/src/pages/tools/styles.module.css
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 c32e2915..86438a9b 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 {
@@ -79,6 +109,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;
@@ -102,6 +143,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),
};
}
@@ -159,6 +203,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono {
event,
schema_version: schemaVersion,
page,
+ client_id: user.clientId,
extra_data: gated,
});
} catch (e) {
@@ -220,6 +265,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 4fa50457..03bd140e 100644
--- a/backend/src/auth.ts
+++ b/backend/src/auth.ts
@@ -175,4 +175,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/components/navbar/index.tsx b/frontend/src/components/navbar/index.tsx
index 05147e89..6ebbef7d 100644
--- a/frontend/src/components/navbar/index.tsx
+++ b/frontend/src/components/navbar/index.tsx
@@ -1,7 +1,4 @@
-import {
- PageName,
- pageNameAtom,
-} from '@/contexts/pageContext';
+import { PageName, pageNameAtom } from '@/contexts/pageContext';
import { useAtom } from 'jotai';
import classes from './styles.module.css';
@@ -24,6 +21,7 @@ const pageNames: Page[] = [
{ name: PageName.Draft, title: 'Draft', hint: 'Generate suggestions' },
{ name: PageName.Revise, title: 'Revise', hint: 'Improve your text' },
{ name: PageName.Chat, title: 'Chat', hint: 'Ask about your doc' },
+ { name: PageName.Tools, title: 'Tools', hint: 'Launch writing tools' },
];
export default function Navbar() {
@@ -43,5 +41,5 @@ export default function Navbar() {
))}
- );
+ );
}
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 4596b267..62ed2dd8 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,8 +51,13 @@ 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, timestamp: Date.now() / 1000 }),
+ body: JSON.stringify({
+ ...filtered,
+ timestamp: Date.now() / 1000,
+ }),
});
} catch {
// Best-effort; never disrupt the UI on a logging failure.
diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx
index 55350bdf..9bf4b6ff 100644
--- a/frontend/src/pages/app/index.tsx
+++ b/frontend/src/pages/app/index.tsx
@@ -25,6 +25,7 @@ import { OnboardingCarousel } from '../carousel/OnboardingCarousel';
import Chat from '../chat';
import Draft from '../draft';
import Revise from '../revise';
+import Tools from '../tools';
import classes from './styles.module.css';
import Navbar from '@/components/navbar';
import { Reshaped, Button } from 'reshaped';
@@ -91,7 +92,13 @@ function DeviceAuthStatus({
{authorization.verificationUri ? (
- Open approval page
+
+ Open approval page
+
) : null}
Open the approval page and enter the code above to continue.
@@ -103,8 +110,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(() => {
@@ -302,6 +315,8 @@ function AppInner() {
return ;
case PageName.Draft:
return ;
+ case PageName.Tools:
+ return ;
}
return null;
}
@@ -429,7 +444,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/tools/index.tsx b/frontend/src/pages/tools/index.tsx
new file mode 100644
index 00000000..5fb549e7
--- /dev/null
+++ b/frontend/src/pages/tools/index.tsx
@@ -0,0 +1,182 @@
+/**
+ * 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[] = [
+ {
+ 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}
+
+
+
+
+
+ Or open a tool by URL
+
+
+ Opens directly in your browser. The tool signs you in itself
+ (device flow); no document is shared.
+
+
+ setAdhocUrl(e.target.value)}
+ />
+
+ Open
+
+
+
+
+ );
+}
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;
+}
From 8ace069fdc05b728c4cef01d9c137e370627c5f2 Mon Sep 17 00:00:00 2001
From: "Kenneth C. Arnold"
Date: Fri, 24 Jul 2026 11:18:43 -0400
Subject: [PATCH 2/4] A test / demo tool to show how it works
---
frontend/src/pages/tools/index.tsx | 10 +
sandbox/test-tool/README.md | 46 +++++
sandbox/test-tool/index.html | 302 +++++++++++++++++++++++++++++
3 files changed, 358 insertions(+)
create mode 100644 sandbox/test-tool/README.md
create mode 100644 sandbox/test-tool/index.html
diff --git a/frontend/src/pages/tools/index.tsx b/frontend/src/pages/tools/index.tsx
index 5fb549e7..9b3d7b9d 100644
--- a/frontend/src/pages/tools/index.tsx
+++ b/frontend/src/pages/tools/index.tsx
@@ -39,6 +39,16 @@ interface FirstPartyTool {
* 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',
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
+ API base URL (where the backend runs)
+
+
+ Reload
+
+
+
+
+ 2. Exchange the grant
+
+ Grant from URL fragment:
+ (none — open me via the sidebar's Launch button)
+
+ Exchange grant → token
+ Waiting.
+
+
+
+
+ 3. Use the token (Platform API v0)
+ Call LLM proxy
+ Write a log line
+ Re-fetch document
+ Revoke token
+
+
+
+
+
+
+
From 41221c6c4b0ded81aa1b83aec67051f7bd121b84 Mon Sep 17 00:00:00 2001
From: "Kenneth C. Arnold"
Date: Fri, 24 Jul 2026 11:19:12 -0400
Subject: [PATCH 3/4] update ignores
---
backend/.gitignore | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/backend/.gitignore b/backend/.gitignore
index 4d7214c0..910a9298 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/
\ No newline at end of file
From 47d3eb1dc4bc4dbab8ef3ff21b7a5f315da34e1b Mon Sep 17 00:00:00 2001
From: "Kenneth C. Arnold"
Date: Sat, 25 Jul 2026 10:01:24 -0400
Subject: [PATCH 4/4] nit
---
backend/.gitignore | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/.gitignore b/backend/.gitignore
index 910a9298..01926296 100644
--- a/backend/.gitignore
+++ b/backend/.gitignore
@@ -4,4 +4,4 @@ dist
logs/*.jsonl
*.log
*.pid
-data/
\ No newline at end of file
+data/