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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,4 @@ dist
logs/*.jsonl
*.log
*.pid
data/*.db
data/*.db-shm
data/*.db-wal
data/
18 changes: 16 additions & 2 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down
20 changes: 17 additions & 3 deletions backend/src/__tests__/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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', () => {
Expand All @@ -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;
};
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/__tests__/erasure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ async function logSomething(userId: string) {
event: 'saved',
schema_version: 1,
page: null,
client_id: null,
extra_data: {},
});
}

function meterSomething(userId: string) {
recordUsage({
userId,
clientId: null,
provider: 'openai',
endpoint: 'chat/completions',
model: 'gpt-4o',
Expand Down
209 changes: 209 additions & 0 deletions backend/src/__tests__/handoff.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createApp>, 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<string> {
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<unknown> {
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();
});
});
4 changes: 4 additions & 0 deletions backend/src/__tests__/logging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe('appendLog', () => {
event: 'click',
schema_version: 1,
page: 'draft',
client_id: null,
extra_data: { a: 1 },
});
await appendLog({
Expand All @@ -58,6 +59,7 @@ describe('appendLog', () => {
event: 'type',
schema_version: 1,
page: 'chat',
client_id: null,
extra_data: {},
});

Expand All @@ -77,6 +79,7 @@ describe('appendLog', () => {
event: 'x',
schema_version: 1,
page: 'draft',
client_id: null,
extra_data: {},
}),
).rejects.toThrow();
Expand Down Expand Up @@ -121,6 +124,7 @@ describe('zipLogs', () => {
event: 'x',
schema_version: 1,
page: 'draft',
client_id: null,
extra_data: {},
});
const zip = await zipLogs();
Expand Down
2 changes: 2 additions & 0 deletions backend/src/__tests__/openaiProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ const real = (id: string) =>
isAnonymous: false,
loggingConsent: CONSENT_LEVELS[2],
isAllowed: true,
clientId: null,
}) as SessionUser;
const anon = (id: string) =>
({
id,
isAnonymous: true,
loggingConsent: FULL_CONSENT_LEVEL,
isAllowed: true,
clientId: null,
}) as SessionUser;


Expand Down
Loading
Loading