papilot 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -8,6 +8,6 @@
8
8
  document.documentElement.dataset.theme = dark ? 'dark' : 'light';
9
9
  document.querySelector('meta[name="theme-color"]').content = dark ? '#26272b' : '#ffffff';
10
10
  })();
11
- </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-BkowXmKr.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-Dq3s-vhH.css">
11
+ </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-DTScOfwx.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BB0zC0wj.css">
13
13
  </head><body><div id="root"></div></body></html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
@@ -62,7 +62,7 @@
62
62
  "doctor": "node bin/papilot.mjs doctor",
63
63
  "service:install": "tsx scripts/service.ts install",
64
64
  "service:stop": "tsx scripts/service.ts stop",
65
- "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs",
65
+ "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-accounts.mjs",
66
66
  "site": "node scripts/build-site.mjs",
67
67
  "site:video": "node scripts/build-site.mjs --video",
68
68
  "prepack": "npm run build && npm run setup:review"
@@ -70,6 +70,7 @@
70
70
  "dependencies": {
71
71
  "@fastify/compress": "^9.2.2",
72
72
  "@fastify/static": "^10.1.7",
73
+ "@simplewebauthn/server": "^14.0.3",
73
74
  "commander": "^15.0.0",
74
75
  "diff": "^9.0.0",
75
76
  "fastify": "^5.12.7",
@@ -80,6 +81,7 @@
80
81
  "devDependencies": {
81
82
  "@anthropic-ai/claude-agent-sdk": "^0.3.311",
82
83
  "@openai/codex": "0.164.0",
84
+ "@simplewebauthn/browser": "^14.0.0",
83
85
  "@types/node": "^22.20.5",
84
86
  "@types/react": "^19.3.0",
85
87
  "@types/react-dom": "^19.3.0",
package/src/notes.ts CHANGED
@@ -14,7 +14,7 @@ export function reviewNotesInstructions(notes: NoteSummary[]) {
14
14
 
15
15
  export function notesInstructions(notes: Annotation[]) {
16
16
  const items = notes.map((note, index) => {
17
- const where = [`Page ${note.page + 1}`, note.source ? `${note.source.file} ${note.source.lines[0] === note.source.lines[1] ? `line ${note.source.lines[0]}` : `lines ${note.source.lines[0]}–${note.source.lines[1]}`}` : ''].filter(Boolean).join(' · ');
17
+ const where = [`Page ${note.page + 1}`, note.source ? `${note.source.file} ${note.source.lines[0] === note.source.lines[1] ? `line ${note.source.lines[0]}` : `lines ${note.source.lines[0]}–${note.source.lines[1]}`}` : '', note.author ? `from ${note.author.name}` : ''].filter(Boolean).join(' · ');
18
18
  const source = note.source?.excerpt ? [' LaTeX source of the marked region:', ' ```latex', ...note.source.excerpt.split('\n').map(line => ' ' + line), ' ```'] : [];
19
19
  return [`${index + 1}. ${where}`, ...source, note.quote ? `${QUOTE}${note.quote}"` : REGION, `${NOTE.slice(1)}${note.comment.trim().replace(/\n/g, '\n ')}`].join('\n');
20
20
  });
@@ -0,0 +1,170 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
3
+ import { authorize } from './security.ts';
4
+ import type { Accounts, Session, User } from './accounts.ts';
5
+ import type { Store } from './store.ts';
6
+ import type { Author, PendingRequest, Snapshot, Viewer as ViewerInfo } from '../shared.ts';
7
+
8
+ // Who may use a route. Every route declares one, and the server refuses to
9
+ // start if one does not, so a new route cannot skip the check:
10
+ // public: anyone, including people not signed in (sign-in, the app's files)
11
+ // any: anyone signed in
12
+ // owner: the owner only (adding papers by path, managing people)
13
+ // { project | task | request: param }: a signed-in person who can see the
14
+ // paper that the named route parameter (or body field, from: 'body') belongs to
15
+ export type Access = 'public' | 'any' | 'owner' | { project: string; from?: 'body' } | { task: string } | { request: string };
16
+ // The local listener serves this host and the owner's Tailscale Serve endpoint;
17
+ // the account listener serves the public URL, where people sign in with passkeys.
18
+ // hsts is off when the tailnet endpoint uses plain HTTP on the same host name,
19
+ // which a year of HSTS would break.
20
+ export type Listener = { kind: 'local'; port: number; publicOrigin: string; owner: string } | { kind: 'account'; origin: string; hsts?: boolean };
21
+ export type Viewer = { listener: 'local' } | { listener: 'account'; user: User; session: Session };
22
+
23
+ declare module 'fastify' {
24
+ // hidden: 'ok' answers { ok: true } for a hidden paper's resource, as the route does for a missing one.
25
+ interface FastifyContextConfig { access?: Access; csrf?: false; hidden?: 'ok' }
26
+ interface FastifyRequest { viewer?: Viewer }
27
+ }
28
+
29
+ export const SESSION_COOKIE = '__Host-papilot-session';
30
+ export const equals = (a: string, b: string) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
31
+ export function cookie(header: string | undefined, name: string) {
32
+ for (const part of (header || '').split(';')) {
33
+ const [key, ...value] = part.trim().split('=');
34
+ if (key === name) return value.join('=');
35
+ }
36
+ return '';
37
+ }
38
+ export const isOwner = (viewer: Viewer | undefined) => viewer?.listener === 'local' || viewer?.user.role === 'owner';
39
+ // Messages and notes record who wrote them; the owner's carry no author.
40
+ export const author = (viewer: Viewer | undefined): Author | undefined => viewer?.listener === 'account' && viewer.user.role !== 'owner' ? { id: viewer.user.id, name: viewer.user.name } : undefined;
41
+ export function viewerInfo(viewer: Viewer, accounts: Accounts): ViewerInfo {
42
+ const user = viewer.listener === 'local' ? accounts.owner() : viewer.user;
43
+ return { id: user.id, name: user.name, role: user.role, listener: viewer.listener, owner: accounts.owner().name };
44
+ }
45
+
46
+ // What a person sees of the workspace: the papers shared with them, their
47
+ // tasks and pending requests, and the agents without the owner's login.
48
+ export function visibleSnapshot(snapshot: Snapshot, viewer: Viewer, accounts: Accounts): Snapshot {
49
+ if (isOwner(viewer) || viewer.listener !== 'account') return snapshot;
50
+ const shown = new Set(snapshot.projects.filter(project => accounts.canSee(viewer.user, project.id)).map(project => project.id));
51
+ const tasks = snapshot.tasks.filter(task => shown.has(task.projectId)), taskIds = new Set(tasks.map(task => task.id));
52
+ const redact = <T extends { account: string | null } | undefined>(runtime: T): T => runtime && runtime.account ? { ...runtime, account: 'Signed in' } : runtime;
53
+ return {
54
+ ...snapshot, projects: snapshot.projects.filter(project => shown.has(project.id)), tasks,
55
+ requests: snapshot.requests.filter(request => taskIds.has(request.taskId)),
56
+ runtime: redact(snapshot.runtime),
57
+ ...(snapshot.runtimes ? { runtimes: Object.fromEntries(Object.entries(snapshot.runtimes).map(([id, runtime]) => [id, redact(runtime)])) } : {}),
58
+ };
59
+ }
60
+
61
+ // Attempts per client per minute for the sign-in endpoints. There is no
62
+ // overall cap, which one client could use up to lock everyone out; requests a
63
+ // proxy sent without the client's address share one larger allowance.
64
+ export class Limiter {
65
+ private windows = new Map<string, { count: number; reset: number }>();
66
+ constructor(private perClient = 30, private unknown = 600, private now = () => Date.now()) {}
67
+ take(client: string) {
68
+ const now = this.now();
69
+ let window = this.windows.get(client);
70
+ if (!window || window.reset <= now) {
71
+ if (this.windows.size >= 1000) this.windows.delete(this.windows.keys().next().value!);
72
+ window = { count: 0, reset: now + 60000 };
73
+ this.windows.delete(client); this.windows.set(client, window);
74
+ }
75
+ if (window.count >= (client === 'unknown' ? this.unknown : this.perClient)) return false;
76
+ window.count++;
77
+ return true;
78
+ }
79
+ }
80
+ // Every public connection reaches Papilot from the proxy on this host, which
81
+ // appends the client's address last. An IPv6 client counts by its /64, since
82
+ // one host can use any address in it.
83
+ export function clientAddress(request: FastifyRequest) {
84
+ const address = String(request.headers['x-forwarded-for'] || '').split(',').map(part => part.trim()).filter(Boolean).at(-1)?.replace(/%.*$/, '') || '';
85
+ if (!address) return 'unknown';
86
+ const v4 = address.match(/(\d{1,3}(?:\.\d{1,3}){3})$/);
87
+ if (v4 || !address.includes(':')) return v4 ? v4[1] : address;
88
+ const [head, tail] = address.toLowerCase().split('::'), front = head ? head.split(':') : [], back = tail ? tail.split(':') : [];
89
+ const groups = tail === undefined ? front : [...front, ...Array(Math.max(0, 8 - front.length - back.length)).fill('0'), ...back];
90
+ return groups.slice(0, 4).map(group => group.replace(/^0+(?=.)/, '')).join(':') + '::/64';
91
+ }
92
+
93
+ const deny = (reply: FastifyReply, code: number, error: string) => reply.code(code).send({ error });
94
+
95
+ export function guard(app: FastifyInstance, options: { listener: Listener; accounts: Accounts; store: Store; csrf: string; request: (id: string) => PendingRequest | undefined }) {
96
+ const { listener, accounts, store } = options;
97
+ app.addHook('onRoute', route => {
98
+ // @fastify/static serves the built app's files under /*; it takes no config.
99
+ const access = route.config?.access ?? (route.url === '/*' ? 'public' : undefined);
100
+ if (!access) throw new Error(`${route.method} ${route.url} has no access rule.`);
101
+ if (typeof access === 'object' && !('from' in access && access.from === 'body')) {
102
+ const param = Object.values(access)[0];
103
+ if (!new RegExp(`:${param}(/|$)`).test(route.url)) throw new Error(`${route.method} ${route.url} has no :${param} parameter for its access rule.`);
104
+ }
105
+ route.config = { ...route.config, access };
106
+ });
107
+ const csrfFor = (viewer: Viewer) => viewer.listener === 'local' ? options.csrf : viewer.session.csrf;
108
+ // A paper the person cannot see answers like one that does not exist: the
109
+ // error its route gives for a missing paper, task or request.
110
+ const hidden = (viewer: Viewer, access: Access, values: Record<string, unknown>) => {
111
+ if (isOwner(viewer) || typeof access !== 'object' || viewer.listener !== 'account') return null;
112
+ const can = (projectId: string) => accounts.canSee(viewer.user, projectId);
113
+ const value = String(values?.[Object.values(access)[0] as string] ?? '');
114
+ if ('project' in access && !can(value)) return 'Paper project not found.';
115
+ if ('task' in access) { const task = store.tasks().find(task => task.id === value); if (task && !can(task.projectId)) return 'Task not found.'; }
116
+ if ('request' in access) {
117
+ const pending = options.request(value), task = pending && store.tasks().find(task => task.id === pending.taskId);
118
+ if (pending && (!task || !can(task.projectId))) return 'This request has ended or the connection has restarted.';
119
+ }
120
+ return null;
121
+ };
122
+ const refuse = (request: FastifyRequest, reply: FastifyReply, message: string) => request.routeOptions.config.hidden === 'ok' ? reply.send({ ok: true }) : deny(reply, 400, message);
123
+ app.addHook('onRequest', async (request, reply) => {
124
+ reply.header('X-Content-Type-Options', 'nosniff').header('Referrer-Policy', 'same-origin').header('X-Frame-Options', 'SAMEORIGIN').header('Cache-Control', 'no-store');
125
+ // A request no route matched gets the app or a plain 404 from the not-found handler.
126
+ const config = request.routeOptions.config, access = request.is404 ? 'public' : config?.access;
127
+ if (listener.kind === 'local') {
128
+ const denied = authorize(request.headers, request.raw.socket.remoteAddress, listener.port, listener.publicOrigin, listener.owner);
129
+ if (denied) return deny(reply, 403, denied);
130
+ // Funnel switched on for the tailnet endpoint would put it on the internet.
131
+ if (request.headers['tailscale-funnel-request']) return deny(reply, 403, 'This address is only for your tailnet. People outside it sign in at PAPILOT_ACCOUNT_ORIGIN.');
132
+ request.viewer = { listener: 'local' };
133
+ } else {
134
+ // Everything here came from the internet, whatever its headers claim.
135
+ const origin = new URL(listener.origin);
136
+ if (request.headers.host !== origin.host) return deny(reply, 403, 'Open Papilot at its public address.');
137
+ // Other sites may link to the app's pages, such as an invite link in a
138
+ // web mail, but may not request anything else from them.
139
+ const site = request.headers['sec-fetch-site'];
140
+ const page = (request.method === 'GET' || request.method === 'HEAD') && access === 'public' && request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document';
141
+ if ((site === 'same-site' || site === 'cross-site') && !page) return deny(reply, 403, 'The request origin does not match.');
142
+ if ((request.headers.origin || (request.method !== 'GET' && request.method !== 'HEAD')) && request.headers.origin !== listener.origin) return deny(reply, 403, 'The request origin does not match.');
143
+ if (listener.hsts) reply.header('Strict-Transport-Security', 'max-age=31536000');
144
+ const found = accounts.session(cookie(request.headers.cookie, SESSION_COOKIE));
145
+ if (found) request.viewer = { listener: 'account', user: found.user, session: found.session };
146
+ }
147
+ if (!access) return deny(reply, 404, 'Not found');
148
+ const viewer = request.viewer;
149
+ if (access === 'public') return;
150
+ if (!viewer) return deny(reply, 401, 'Sign in to continue.');
151
+ if (request.method === 'POST' && config.csrf !== false && !equals(String(request.headers['x-papilot-token'] || ''), csrfFor(viewer))) return deny(reply, 403, 'This page has expired. Refresh it and try again.');
152
+ if (access === 'owner' && !isOwner(viewer)) return deny(reply, 403, 'Only the owner of this Papilot can do this.');
153
+ const message = typeof access === 'object' && !('from' in access) ? hidden(viewer, access, request.params as Record<string, unknown>) : null;
154
+ if (message) return refuse(request, reply, message);
155
+ });
156
+ // The body can arrive long after the headers, so a signed-in person's
157
+ // session and papers are checked again once it has, along with a paper
158
+ // named in the body. Removal or unsharing meanwhile stops the request.
159
+ app.addHook('preHandler', async (request, reply) => {
160
+ const config = request.routeOptions.config, access = config?.access;
161
+ if (request.viewer?.listener !== 'account' || request.is404 || !access || access === 'public') return;
162
+ const found = accounts.session(cookie(request.headers.cookie, SESSION_COOKIE));
163
+ if (!found || found.session.id !== request.viewer.session.id) return deny(reply, 401, 'Sign in to continue.');
164
+ const viewer: Viewer = request.viewer = { listener: 'account', user: found.user, session: found.session };
165
+ if (access === 'owner' && !isOwner(viewer)) return deny(reply, 403, 'Only the owner of this Papilot can do this.');
166
+ const message = typeof access === 'object' ? hidden(viewer, access, ('from' in access ? request.body : request.params) as Record<string, unknown>) : null;
167
+ if (message) return refuse(request, reply, message);
168
+ });
169
+ return { csrfFor };
170
+ }
@@ -0,0 +1,226 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import os from 'node:os';
4
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
5
+ import type { AuthenticationResponseJSON, AuthenticatorTransport, RegistrationResponseJSON } from '@simplewebauthn/server';
6
+ import { isoBase64URL } from '@simplewebauthn/server/helpers';
7
+ import type { Store } from './store.ts';
8
+ import type { Device, People, Person } from '../shared.ts';
9
+
10
+ // People who sign in at the public URL, their passkeys, invites and browser
11
+ // sessions. None of these writes emit the store's change event: every change
12
+ // makes each open workspace reload its state, which would itself touch a session.
13
+ export interface User { id: string; name: string; role: 'owner' | 'member'; handle: string; createdAt: number; lastSeenAt: number | null }
14
+ interface Credential { id: string; userId: string; publicKey: string; counter: number; transports?: string[]; label: string; createdAt: number; lastUsedAt: number | null }
15
+ interface Invite { userId: string; createdAt: number }
16
+ export interface Session { id: string; userId: string; csrf: string; label: string; createdAt: number; seenAt: number; expires: number }
17
+ interface Challenge { type: 'register' | 'authenticate'; invite?: string; userId?: string; expires: number }
18
+
19
+ const MINUTE = 60000, DAY = 1440 * MINUTE;
20
+ // A session slides for 30 days of use and ends 90 days after sign-in regardless.
21
+ const SESSION = 30 * DAY, SESSION_LIMIT = 90 * DAY, SEEN = 10 * MINUTE;
22
+ // An owner invite signs in as the owner from anywhere, so it is short-lived.
23
+ const INVITE = { member: 7 * DAY, owner: 15 * MINUTE }, CHALLENGE = 5 * MINUTE, CHALLENGES = 1000;
24
+ const digest = (token: string) => createHash('sha256').update(token).digest('hex');
25
+ const secret = () => randomBytes(32).toString('base64url');
26
+
27
+ export class AccountError extends Error { statusCode = 400 }
28
+
29
+ // The passkey ceremonies, which tests replace with fakes.
30
+ export const ceremonies = { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse };
31
+
32
+ export class Accounts extends EventEmitter {
33
+ private challenges = new Map<string, Challenge>();
34
+ constructor(private store: Store, readonly origin: string | null, private passkeys = ceremonies, private now = () => Date.now()) {
35
+ super();
36
+ if (!this.users().some(user => user.role === 'owner')) this.saveUser({ id: randomUUID(), name: os.userInfo().username || 'Owner', role: 'owner', handle: secret(), createdAt: this.now(), lastSeenAt: null });
37
+ this.purge();
38
+ }
39
+ private get db() { return this.store.db; }
40
+ private rows<T>(sql: string, ...values: (string | number)[]) { return (this.db.prepare(sql).all(...values) as { data: string }[]).map(row => JSON.parse(row.data) as T); }
41
+ users() { return this.rows<User>('SELECT data FROM users ORDER BY rowid'); }
42
+ user(id: string) { return this.users().find(user => user.id === id); }
43
+ owner() { return this.users().find(user => user.role === 'owner')!; }
44
+ private saveUser(user: User) { this.db.prepare('INSERT INTO users VALUES(?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(user.id, JSON.stringify(user)); return user; }
45
+ private member(id: string) {
46
+ const user = this.user(id);
47
+ if (!user) throw new AccountError('This person is no longer in Papilot.');
48
+ return user;
49
+ }
50
+ projects(userId: string) { return (this.db.prepare('SELECT project_id FROM members WHERE user_id=? ORDER BY project_id').all(userId) as { project_id: string }[]).map(row => row.project_id); }
51
+ // Read on every request, so unsharing a paper takes effect at once.
52
+ canSee(user: User, projectId: string) { return user.role === 'owner' || Boolean(this.db.prepare('SELECT 1 FROM members WHERE project_id=? AND user_id=?').get(projectId, user.id)); }
53
+ private credentials(userId: string) { return this.rows<Credential>('SELECT data FROM credentials WHERE user_id=? ORDER BY rowid', userId); }
54
+ private sessions(userId: string) { return this.rows<Session>('SELECT data FROM sessions WHERE user_id=? AND expires>? ORDER BY rowid', userId, this.now()); }
55
+
56
+ people(): People {
57
+ const devices = (user: User): Device[] => [
58
+ ...this.credentials(user.id).map(c => ({ id: c.id, kind: 'passkey' as const, label: c.label, createdAt: c.createdAt, lastUsedAt: c.lastUsedAt })),
59
+ ...this.sessions(user.id).map(s => ({ id: s.id, kind: 'session' as const, label: s.label, createdAt: s.createdAt, lastUsedAt: s.seenAt })),
60
+ ];
61
+ const person = (user: User): Person => ({ id: user.id, name: user.name, role: user.role, joined: this.credentials(user.id).length > 0, projects: user.role === 'owner' ? [] : this.projects(user.id), lastSeenAt: user.lastSeenAt, devices: devices(user) });
62
+ return { origin: this.origin, people: this.users().map(person) };
63
+ }
64
+ // A new person sees the chosen papers once they open their invite.
65
+ addPerson(name: string, projects: string[]) {
66
+ const user = this.saveUser({ id: randomUUID(), name, role: 'member', handle: secret(), createdAt: this.now(), lastSeenAt: null });
67
+ this.share(user.id, projects);
68
+ return { id: user.id, link: this.invite(user.id) };
69
+ }
70
+ rename(userId: string, name: string) { this.saveUser({ ...this.member(userId), name }); }
71
+ share(userId: string, projects: string[]) {
72
+ if (this.member(userId).role === 'owner') throw new AccountError('The owner sees every paper.');
73
+ this.store.transaction(() => {
74
+ this.db.prepare('DELETE FROM members WHERE user_id=?').run(userId);
75
+ for (const project of new Set(projects)) this.db.prepare('INSERT INTO members VALUES(?,?)').run(project, userId);
76
+ });
77
+ this.emit('revoked', { userId });
78
+ }
79
+ // One link registers one passkey: a new person's first, or another device's.
80
+ invite(userId: string) {
81
+ const user = this.member(userId);
82
+ if (!this.origin) throw new AccountError('Set PAPILOT_ACCOUNT_ORIGIN to the public URL before inviting people.');
83
+ const token = secret(), invite: Invite = { userId, createdAt: this.now() };
84
+ this.db.prepare('INSERT INTO invites VALUES(?,?,?,?)').run(digest(token), userId, this.now() + INVITE[user.role], JSON.stringify(invite));
85
+ return `${this.origin}/invite#${token}`;
86
+ }
87
+ remove(userId: string) {
88
+ if (this.member(userId).role === 'owner') throw new AccountError('The owner cannot be removed.');
89
+ this.store.transaction(() => {
90
+ for (const table of ['credentials', 'invites', 'sessions', 'members']) this.db.prepare(`DELETE FROM ${table} WHERE user_id=?`).run(userId);
91
+ this.db.prepare('DELETE FROM users WHERE id=?').run(userId);
92
+ });
93
+ for (const [key, challenge] of this.challenges) if (challenge.userId === userId) this.challenges.delete(key);
94
+ this.emit('revoked', { userId });
95
+ }
96
+ // A lost device: its passkey or its signed-in session.
97
+ revoke(userId: string, deviceId: string) {
98
+ const removed = Number(this.db.prepare('DELETE FROM credentials WHERE id=? AND user_id=?').run(deviceId, userId).changes)
99
+ + Number(this.db.prepare('DELETE FROM sessions WHERE id=? AND user_id=?').run(deviceId, userId).changes);
100
+ if (!removed) throw new AccountError('This device is no longer signed in.');
101
+ this.emit('revoked', { userId, sessionId: deviceId });
102
+ }
103
+ signOutEverywhere(userId: string) {
104
+ this.db.prepare('DELETE FROM sessions WHERE user_id=?').run(userId);
105
+ this.emit('revoked', { userId });
106
+ }
107
+
108
+ private startSession(userId: string, label: string) {
109
+ const token = secret(), now = this.now();
110
+ const session: Session = { id: randomUUID(), userId, csrf: secret(), label, createdAt: now, seenAt: now, expires: now + SESSION };
111
+ this.db.prepare('INSERT INTO sessions VALUES(?,?,?,?,?)').run(digest(token), session.id, userId, session.expires, JSON.stringify(session));
112
+ return { token, session };
113
+ }
114
+ // The signed-in person for a session cookie. Use slides the expiry, but at
115
+ // most every ten minutes, so ordinary polling does not write.
116
+ session(token: string) {
117
+ if (!token) return null;
118
+ const hash = digest(token), now = this.now();
119
+ const row = this.db.prepare('SELECT data FROM sessions WHERE token_hash=? AND expires>?').get(hash, now) as { data: string } | undefined;
120
+ if (!row) return null;
121
+ let session = JSON.parse(row.data) as Session;
122
+ const user = this.user(session.userId);
123
+ if (!user) return null;
124
+ if (now - session.seenAt > SEEN) {
125
+ session = { ...session, seenAt: now, expires: Math.min(now + SESSION, session.createdAt + SESSION_LIMIT) };
126
+ this.db.prepare('UPDATE sessions SET expires=?, data=? WHERE token_hash=?').run(session.expires, JSON.stringify(session), hash);
127
+ this.saveUser({ ...user, lastSeenAt: now });
128
+ }
129
+ return { session, user };
130
+ }
131
+ endSession(sessionId: string) { this.db.prepare('DELETE FROM sessions WHERE id=?').run(sessionId); this.emit('revoked', { sessionId }); }
132
+ purge() {
133
+ this.db.prepare('DELETE FROM invites WHERE expires<=?').run(this.now());
134
+ this.db.prepare('DELETE FROM sessions WHERE expires<=?').run(this.now());
135
+ }
136
+
137
+ private get rpID() {
138
+ if (!this.origin) throw new AccountError('Sign-in is not set up on this Papilot.');
139
+ return new URL(this.origin).hostname;
140
+ }
141
+ private remember(challenge: string, value: Omit<Challenge, 'expires'>) {
142
+ const now = this.now();
143
+ for (const [key, entry] of this.challenges) if (entry.expires <= now) this.challenges.delete(key);
144
+ while (this.challenges.size >= CHALLENGES) this.challenges.delete(this.challenges.keys().next().value!);
145
+ this.challenges.set(challenge, { ...value, expires: now + CHALLENGE });
146
+ }
147
+ // Each challenge is used once, whatever the ceremony's outcome.
148
+ private take(challenge: string) {
149
+ const entry = this.challenges.get(challenge);
150
+ this.challenges.delete(challenge);
151
+ return entry && entry.expires > this.now() ? entry : undefined;
152
+ }
153
+ private openInvite(token: string) {
154
+ const hash = digest(String(token || ''));
155
+ const row = this.db.prepare('SELECT user_id FROM invites WHERE token_hash=? AND expires>?').get(hash, this.now()) as { user_id: string } | undefined;
156
+ const user = row && this.user(row.user_id);
157
+ if (!user) throw new AccountError('This invite link has expired or was already used. Ask for a new one.');
158
+ return { hash, user };
159
+ }
160
+ inviteInfo(token: string, paperName: (id: string) => string | undefined) {
161
+ const { user } = this.openInvite(token);
162
+ return { name: user.name, owner: user.role === 'owner', inviter: this.owner().name, papers: this.projects(user.id).map(paperName).filter(Boolean), adding: this.credentials(user.id).length > 0 };
163
+ }
164
+ async registrationOptions(token: string) {
165
+ const { hash, user } = this.openInvite(token);
166
+ const options = await this.passkeys.generateRegistrationOptions({
167
+ rpName: 'Papilot', rpID: this.rpID, userName: user.name, userDisplayName: user.name, userID: isoBase64URL.toBuffer(user.handle),
168
+ attestationType: 'none', excludeCredentials: this.credentials(user.id).map(c => ({ id: c.id, transports: c.transports as AuthenticatorTransport[] | undefined })),
169
+ authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
170
+ });
171
+ this.remember(options.challenge, { type: 'register', invite: hash, userId: user.id });
172
+ return options;
173
+ }
174
+ async register(token: string, response: RegistrationResponseJSON, label: string) {
175
+ const { hash } = this.openInvite(token);
176
+ let bound: Challenge | undefined;
177
+ const result = await this.passkeys.verifyRegistrationResponse({
178
+ response, expectedOrigin: this.origin!, expectedRPID: this.rpID, requireUserVerification: true,
179
+ expectedChallenge: challenge => { bound = this.take(challenge); return bound?.type === 'register' && bound.invite === hash; },
180
+ }).catch(() => ({ verified: false as const }));
181
+ if (!result.verified || !bound?.userId) throw new AccountError('The passkey could not be created. Try again.');
182
+ const { credential } = result.registrationInfo, userId = bound.userId;
183
+ // Verification awaited; using the invite, saving the passkey and signing in
184
+ // happen together or not at all, so one link cannot register twice.
185
+ return this.store.transaction(() => {
186
+ if (Number(this.db.prepare('DELETE FROM invites WHERE token_hash=? AND expires>?').run(hash, this.now()).changes) !== 1) throw new AccountError('This invite link has expired or was already used. Ask for a new one.');
187
+ if (!this.user(userId)) throw new AccountError('This person is no longer in Papilot.');
188
+ const saved: Credential = { id: credential.id, userId, publicKey: isoBase64URL.fromBuffer(credential.publicKey), counter: credential.counter, transports: credential.transports, label, createdAt: this.now(), lastUsedAt: null };
189
+ this.db.prepare('INSERT INTO credentials VALUES(?,?,?)').run(saved.id, userId, JSON.stringify(saved));
190
+ return this.startSession(userId, label);
191
+ });
192
+ }
193
+ async authenticationOptions() {
194
+ const options = await this.passkeys.generateAuthenticationOptions({ rpID: this.rpID, userVerification: 'required', allowCredentials: [] });
195
+ this.remember(options.challenge, { type: 'authenticate' });
196
+ return options;
197
+ }
198
+ async authenticate(response: AuthenticationResponseJSON, label: string) {
199
+ const failed = () => new AccountError('This passkey could not sign you in. Ask the owner for a new invite if it was removed.');
200
+ const row = this.db.prepare('SELECT data FROM credentials WHERE id=?').get(String(response?.id || '')) as { data: string } | undefined;
201
+ const credential = row && JSON.parse(row.data) as Credential, user = credential && this.user(credential.userId);
202
+ // A discoverable passkey names its user; it must be the user it was registered to.
203
+ if (!credential || !user || response.response?.userHandle !== user.handle) throw failed();
204
+ let bound: Challenge | undefined;
205
+ const result = await this.passkeys.verifyAuthenticationResponse({
206
+ response, expectedOrigin: this.origin!, expectedRPID: this.rpID, requireUserVerification: true,
207
+ credential: { id: credential.id, publicKey: isoBase64URL.toBuffer(credential.publicKey), counter: credential.counter, transports: credential.transports },
208
+ expectedChallenge: challenge => { bound = this.take(challenge); return bound?.type === 'authenticate'; },
209
+ }).catch(() => ({ verified: false as const }));
210
+ if (!result.verified || !bound) throw failed();
211
+ return this.store.transaction(() => {
212
+ if (!this.user(user.id) || !this.db.prepare('SELECT 1 FROM credentials WHERE id=?').get(credential.id)) throw failed();
213
+ const used: Credential = { ...credential, counter: result.authenticationInfo.newCounter, lastUsedAt: this.now() };
214
+ this.db.prepare('UPDATE credentials SET data=? WHERE id=?').run(JSON.stringify(used), credential.id);
215
+ return this.startSession(user.id, label);
216
+ });
217
+ }
218
+ }
219
+
220
+ // A short name for the browser a session or passkey came from.
221
+ export function deviceLabel(agent: string | undefined) {
222
+ const ua = agent || '';
223
+ const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
224
+ const system = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'macOS' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : '';
225
+ return system ? `${browser} on ${system}` : browser;
226
+ }