Skip to content
Draft
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
8 changes: 6 additions & 2 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ thin: it proxies OpenAI requests with the server-held API key and writes study l
- **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
anonymizes usage rows.
anonymizes usage rows. The fixed public Mindmap OAuth client is provisioned through
Better Auth's adapter at startup (`oauth-clients.ts`); app migration v7 performs the
one-time cleanup of clients created while dynamic registration was enabled.

## Commands

Expand All @@ -90,7 +92,9 @@ pays for sessionless requests — without it they're refused wherever auth is on
"delete my data" to purge a user's PostHog person; unset => that step no-ops), `LOG_DIR`
(overrides just the logs subdir). Auth: `BETTER_AUTH_ENABLED`, `BETTER_AUTH_SECRET`,
`BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `GOOGLE_CLIENT_ID`,
`GOOGLE_CLIENT_SECRET`. For local dev, run `python scripts/get_env.py` to generate
`GOOGLE_CLIENT_SECRET`, `MINDMAP_OAUTH_CLIENT_ID`, and
`MINDMAP_OAUTH_REDIRECT_URIS` (comma-separated exact callback URLs). For local
dev, run `python scripts/get_env.py` to generate
`backend/.env`; it prompts for `OPENAI_DEMO_API_KEY` too and defaults it to the main key,
since an unset demo key makes every demo/anonymous request 401 once auth is enabled.

Expand Down
3 changes: 2 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ key for it locally),
for `app.db` + `logs/`), `PORT` (default 8000), `DEBUG`, `POSTHOG_PROJECT_TOKEN`,
`POSTHOG_HOST`, `LOG_DIR` (overrides just the logs subdir). Auth (Better Auth) adds
`BETTER_AUTH_ENABLED`, `BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`,
and related vars — see [CLAUDE.md](CLAUDE.md) for the full list.
`MINDMAP_OAUTH_CLIENT_ID`, `MINDMAP_OAUTH_REDIRECT_URIS`, and related vars — see
[CLAUDE.md](CLAUDE.md) for the full list.
18 changes: 18 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@better-auth/oauth-provider": "^1.6.22",
"@hono/node-server": "^1.13.7",
"better-auth": "1.6.22",
"better-sqlite3": "^12.0.0",
Expand Down
3 changes: 3 additions & 0 deletions backend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ describe('DELETE /api/me/activity', () => {

const res = await authApp.request('/api/me/activity', { method: 'DELETE' });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
message: 'Your logged activity has been erased.',
});
await expect(readUserLog('usr-del')).rejects.toThrow();
});

Expand Down
45 changes: 42 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(3);
expect(conn.pragma('user_version', { simple: true })).toBe(7);

// The table is usable, not merely declared.
const table = conn
Expand All @@ -45,6 +45,11 @@ describe('migrations', () => {
)
.get();
expect(grantTable).toBeDefined();

// v6 removes the now-unnecessary transient OAuth room selection.
expect(
conn.prepare(`SELECT name FROM sqlite_master WHERE name='oauth_room_selection'`).get(),
).toBeUndefined();
});

it('is idempotent across reopens — a second open re-runs nothing', () => {
Expand All @@ -58,12 +63,46 @@ 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(3);
expect(conn.pragma('user_version', { simple: true })).toBe(7);
const rows = conn.prepare(`SELECT COUNT(*) AS n FROM llm_usage`).get() as {
n: number;
};
expect(rows.n).toBe(1);
});

it('removes stale dynamic OAuth clients once when upgrading from v6', () => {
const file = path.join(dataDir, 'app.db');
const legacy = new Database(file);
legacy.exec(`
CREATE TABLE oauthClient (
id TEXT PRIMARY KEY,
clientId TEXT NOT NULL UNIQUE
);
INSERT INTO oauthClient (id, clientId) VALUES
('trusted', 'configured-mindmap'),
('stale', 'old-dynamic-client');
PRAGMA user_version = 6;
`);
legacy.close();
process.env.MINDMAP_OAUTH_CLIENT_ID = 'configured-mindmap';

const conn = db();
expect(conn.pragma('user_version', { simple: true })).toBe(7);
expect(conn.prepare(`SELECT clientId FROM oauthClient`).all()).toEqual([
{ clientId: 'configured-mindmap' },
]);

// Reopening at v7 must not repeat cleanup or remove a later client.
conn.prepare(
`INSERT INTO oauthClient (id, clientId) VALUES ('later', 'later-fixed-client')`,
).run();
closeDb();
expect(db().prepare(`SELECT clientId FROM oauthClient ORDER BY clientId`).all())
.toEqual([
{ clientId: 'configured-mindmap' },
{ clientId: 'later-fixed-client' },
]);
});
});

describe('legacy auth.db rename', () => {
Expand All @@ -87,7 +126,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(3);
expect(conn.pragma('user_version', { simple: true })).toBe(7);
});

it('leaves an existing app.db alone when a stray auth.db is also present', async () => {
Expand Down
31 changes: 27 additions & 4 deletions backend/src/__tests__/erasure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ 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 { eraseLoggedData } from '../erasure.js';
import { eraseAccountData, eraseLoggedData } from '../erasure.js';
import { appendLog } from '../logging.js';
import { deletePosthogPerson } from '../posthog.js';
import { createRoom, listRooms } from '../rooms.js';
import {
DELETED_USER_ID,
recordUsage,
Expand Down Expand Up @@ -73,19 +74,37 @@ function logExists(userId: string): boolean {
}

describe('eraseLoggedData', () => {
it('removes the study log and the analytics profile together', async () => {
it('removes logged activity while preserving active rooms', async () => {
await logSomething('usr-1');
createRoom('usr-1', 'Private draft', {
beforeCursor: 'private', selectedText: '', afterCursor: '',
});
expect(logExists('usr-1')).toBe(true);
expect(listRooms('usr-1')).toHaveLength(1);

await eraseLoggedData('usr-1');

expect(logExists('usr-1')).toBe(false);
expect(listRooms('usr-1')).toHaveLength(1);
expect(deletePosthogPerson).toHaveBeenCalledWith('usr-1');
});
});

describe('account deletion (Better Auth beforeDelete)', () => {
it('does everything withdrawal does, and anonymizes the usage rows on top', async () => {
it('still deletes rooms when a shared activity-erasure operation fails', async () => {
const invalidLogKey = '../usr-partial';
createRoom(invalidLogKey, 'Private draft', {
beforeCursor: 'private', selectedText: '', afterCursor: '',
});

await expect(eraseAccountData(invalidLogKey)).rejects.toBeInstanceOf(
AggregateError,
);
expect(deletePosthogPerson).toHaveBeenCalledWith(invalidLogKey);
expect(listRooms(invalidLogKey)).toEqual([]);
});

it('includes every activity erasure, then deletes rooms and anonymizes usage', async () => {
// Imported here, not at module load: auth.ts opens the DB as a side effect of
// evaluation, so it has to see this test's temp DATA_DIR.
process.env.BETTER_AUTH_SECRET = 'x'.repeat(32);
Expand All @@ -96,15 +115,19 @@ describe('account deletion (Better Auth beforeDelete)', () => {

await logSomething('usr-gone');
meterSomething('usr-gone');
createRoom('usr-gone', 'Private draft', {
beforeCursor: 'private', selectedText: '', afterCursor: '',
});

const beforeDelete = auth.options.user?.deleteUser?.beforeDelete;
expect(beforeDelete).toBeDefined();
// Better Auth hands the hook the full user record; only the id is read.
await beforeDelete?.({ id: 'usr-gone' } as never);

// The withdrawal erasure — this is the half account deletion used to skip.
// Full account erasure includes both activity records and room snapshots.
expect(logExists('usr-gone')).toBe(false);
expect(deletePosthogPerson).toHaveBeenCalledWith('usr-gone');
expect(listRooms('usr-gone')).toEqual([]);

// ...plus the tombstone: the spend survives, detached from the person.
const rows = summarizeUsage(0, Date.now() + 1000);
Expand Down
29 changes: 29 additions & 0 deletions backend/src/__tests__/oauth-room-authorization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { parseOAuthAuthorizationQuery } from '../oauth-room-authorization.js';

describe('OAuth room authorization context', () => {
it('derives the exact launched room and client from the verified query', () => {
expect(
parseOAuthAuthorizationQuery(
new URLSearchParams({
state: 'room_exact.random-csrf',
client_id: 'dynamic-client',
redirect_uri: 'https://mindmap.example/callback',
}).toString(),
),
).toEqual({
state: 'room_exact.random-csrf',
roomId: 'room_exact',
clientId: 'dynamic-client',
redirectUri: 'https://mindmap.example/callback',
});
});

it('fails closed when the authorization lacks a room-bound state', () => {
expect(() =>
parseOAuthAuthorizationQuery(
'state=random&client_id=client&redirect_uri=https%3A%2F%2Fapp.example',
),
).toThrow();
});
});
Loading
Loading