@wtfalch/auth 0.32.0 → 0.34.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/cookies.d.ts CHANGED
@@ -19,6 +19,10 @@ export interface TransactionPayload extends JWTPayload {
19
19
  nx: string;
20
20
  /** The leg was a silent pass (`prompt=none`): an error coming back falls back to interactive sign-in. */
21
21
  sl?: true;
22
+ /** A fresh sign-in started by `startFreshSignIn`: the auth request this transaction belongs to. */
23
+ ar?: string;
24
+ /** The passkey challenge's session id, set by `startFreshPasskey`. */
25
+ fp?: string;
22
26
  }
23
27
  export interface CookieAttributes {
24
28
  path: '/';
@@ -78,6 +82,9 @@ export declare function linkCookieName(options: ResolvedOptions): string;
78
82
  export declare function sealLink(options: ResolvedOptions, payload: Omit<LinkPayload, keyof JWTPayload>): Promise<SetCookie>;
79
83
  export declare function openLink(options: ResolvedOptions, value: string | undefined): Promise<LinkPayload | null>;
80
84
  export declare function clearLink(options: ResolvedOptions): SetCookie;
85
+ /** The header that makes the browser drop the cookie `name`. */
86
+ export declare function clearCookie(name: string, secure: boolean): SetCookie;
87
+ /** Builds the header. A `maxAge` of 0 or less is the clearing form; callers clear through `clearCookie`. */
81
88
  export declare function setCookie(name: string, value: string, maxAge: number, secure: boolean): SetCookie;
82
89
  export declare function cookieFrom(header: string | null, name: string): string | undefined;
83
90
  /** The Cookie header with these cookies replaced or removed, for a proxy to forward. */
package/dist/cookies.js CHANGED
@@ -51,7 +51,7 @@ export async function openSession(options, value) {
51
51
  return payload && typeof payload.idt === 'string' ? payload : null;
52
52
  }
53
53
  export function clearSession(options) {
54
- return setCookie(sessionCookieName(options), '', 0, options.secure);
54
+ return clearCookie(sessionCookieName(options), options.secure);
55
55
  }
56
56
  /** The namespace session cookie: a random handle to a server record, nothing else. */
57
57
  export async function sealHandle(options, sid) {
@@ -96,7 +96,7 @@ export async function openTransaction(options, value) {
96
96
  : null;
97
97
  }
98
98
  export function clearTransaction(options) {
99
- return setCookie(transactionCookieName(options), '', 0, options.secure);
99
+ return clearCookie(transactionCookieName(options), options.secure);
100
100
  }
101
101
  export function silentCookieName(options) {
102
102
  return options.secure ? `__Host-${SILENT}` : SILENT;
@@ -126,7 +126,7 @@ export async function openQrTransaction(options, value) {
126
126
  return payload && typeof payload.dc === 'string' ? payload : null;
127
127
  }
128
128
  export function clearQrTransaction(options) {
129
- return setCookie(qrTransactionCookieName(options), '', 0, options.secure);
129
+ return clearCookie(qrTransactionCookieName(options), options.secure);
130
130
  }
131
131
  export function linkCookieName(options) {
132
132
  return options.secure ? `__Host-${LINK}` : LINK;
@@ -145,7 +145,7 @@ export async function openLink(options, value) {
145
145
  : null;
146
146
  }
147
147
  export function clearLink(options) {
148
- return setCookie(linkCookieName(options), '', 0, options.secure);
148
+ return clearCookie(linkCookieName(options), options.secure);
149
149
  }
150
150
  // A different audience also prevents old static consumers from accepting a
151
151
  // namespace cookie. Hashing keeps the encrypted session below browser limits.
@@ -186,6 +186,11 @@ async function open(options, kind, value) {
186
186
  return null;
187
187
  }
188
188
  }
189
+ /** The header that makes the browser drop the cookie `name`. */
190
+ export function clearCookie(name, secure) {
191
+ return setCookie(name, '', 0, secure);
192
+ }
193
+ /** Builds the header. A `maxAge` of 0 or less is the clearing form; callers clear through `clearCookie`. */
189
194
  export function setCookie(name, value, maxAge, secure) {
190
195
  const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
191
196
  if (maxAge > 0) {
@@ -0,0 +1,26 @@
1
+ export declare const newNonce: () => string;
2
+ export declare function same(a: string, b: string): boolean;
3
+ /**
4
+ * The key that seals a payload, derived from the cookie secret the app
5
+ * already holds, so no new secret exists. The fixed label keeps it apart
6
+ * from every other use of that secret. It is not the label Boule and Archon
7
+ * used (`boule-desktop-handoff`, `archon-desktop-handoff`): a row sealed under
8
+ * the old label fails to open here, so a sign-in in flight when an app cuts
9
+ * over to this handler ends in `payload_invalid`. The window is the row's
10
+ * lifetime, five minutes.
11
+ */
12
+ export declare function sealKey(secret: string): Buffer;
13
+ /** AES-256-GCM. `aad` is authenticated, not secret: it ties the ciphertext to one row and one origin. */
14
+ export declare function seal(key: Buffer, aad: string, plaintext: string): string;
15
+ /** Throws when the ciphertext, the tag or the `aad` is not what `seal` was given. */
16
+ export declare function open(key: Buffer, aad: string, sealed: string): string;
17
+ /**
18
+ * The code the browser and the shell each derive from the challenge, so the
19
+ * person can see both belong to one sign-in: the first 30 bits of
20
+ * sha256("boule-desktop-pair:" + challenge), 5 bits each, shown as `ABC-DEF`.
21
+ * The label is fixed text, not this app's id: it is shared with
22
+ * `@wtfalch/desktop`'s `login.mjs`, which derives the same code
23
+ * independently of which app it is signing into. Changing it breaks pairing
24
+ * for every shell already built against it.
25
+ */
26
+ export declare function pairingCode(challenge: string): string;
@@ -0,0 +1,62 @@
1
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual, } from 'node:crypto';
2
+ /*
3
+ * The handoff's crypto, apart from its store and its handler. It uses
4
+ * `node:crypto` on purpose: the sealed payload's format (`iv|tag|ciphertext`,
5
+ * AES-256-GCM) is what rows already in an app's store hold, and `cookies.ts`'s
6
+ * jose sealing is another format. Only the `./desktop`
7
+ * entry imports this file, so the root entry stays free of `node:crypto`.
8
+ */
9
+ export const newNonce = () => randomBytes(32).toString('base64url');
10
+ export function same(a, b) {
11
+ const x = Buffer.from(a);
12
+ const y = Buffer.from(b);
13
+ return x.length === y.length && timingSafeEqual(x, y);
14
+ }
15
+ /**
16
+ * The key that seals a payload, derived from the cookie secret the app
17
+ * already holds, so no new secret exists. The fixed label keeps it apart
18
+ * from every other use of that secret. It is not the label Boule and Archon
19
+ * used (`boule-desktop-handoff`, `archon-desktop-handoff`): a row sealed under
20
+ * the old label fails to open here, so a sign-in in flight when an app cuts
21
+ * over to this handler ends in `payload_invalid`. The window is the row's
22
+ * lifetime, five minutes.
23
+ */
24
+ export function sealKey(secret) {
25
+ return Buffer.from(hkdfSync('sha256', secret, Buffer.alloc(0), 'wtfalch-auth-desktop-handoff', 32));
26
+ }
27
+ /** AES-256-GCM. `aad` is authenticated, not secret: it ties the ciphertext to one row and one origin. */
28
+ export function seal(key, aad, plaintext) {
29
+ const iv = randomBytes(12);
30
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
31
+ cipher.setAAD(Buffer.from(aad));
32
+ const data = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
33
+ return Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64url');
34
+ }
35
+ /** Throws when the ciphertext, the tag or the `aad` is not what `seal` was given. */
36
+ export function open(key, aad, sealed) {
37
+ const data = Buffer.from(sealed, 'base64url');
38
+ const decipher = createDecipheriv('aes-256-gcm', key, data.subarray(0, 12));
39
+ decipher.setAAD(Buffer.from(aad));
40
+ decipher.setAuthTag(data.subarray(12, 28));
41
+ return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]).toString('utf8');
42
+ }
43
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
44
+ /**
45
+ * The code the browser and the shell each derive from the challenge, so the
46
+ * person can see both belong to one sign-in: the first 30 bits of
47
+ * sha256("boule-desktop-pair:" + challenge), 5 bits each, shown as `ABC-DEF`.
48
+ * The label is fixed text, not this app's id: it is shared with
49
+ * `@wtfalch/desktop`'s `login.mjs`, which derives the same code
50
+ * independently of which app it is signing into. Changing it breaks pairing
51
+ * for every shell already built against it.
52
+ */
53
+ export function pairingCode(challenge) {
54
+ const bits = createHash('sha256')
55
+ .update(`boule-desktop-pair:${challenge}`)
56
+ .digest()
57
+ .readUInt32BE(0);
58
+ let code = '';
59
+ for (let i = 0; i < 6; i++)
60
+ code += CROCKFORD[(bits >>> (27 - 5 * i)) & 31];
61
+ return `${code.slice(0, 3)}-${code.slice(3)}`;
62
+ }
@@ -0,0 +1,13 @@
1
+ import type { DesktopHandoffStore } from './desktop-store.js';
2
+ /**
3
+ * Checks a `DesktopHandoffStore` against the contract the handler relies on and
4
+ * cannot check itself: `admit`'s per-caller cap, `lock`'s exclusivity, the
5
+ * one-a-second `stampPoll` and a row's round trip through `patch` and
6
+ * `remove`. Run it against any store, the Postgres one included, with a
7
+ * function that makes a fresh store over an empty table (a store with
8
+ * `capacity` of at least 4 rows and the default poll gap). It throws an Error
9
+ * naming the broken rule, and uses no test framework.
10
+ *
11
+ * It does not check the store's total capacity, which is the store's own.
12
+ */
13
+ export declare function checkDesktopHandoffStore(create: () => DesktopHandoffStore | Promise<DesktopHandoffStore>): Promise<void>;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Checks a `DesktopHandoffStore` against the contract the handler relies on and
3
+ * cannot check itself: `admit`'s per-caller cap, `lock`'s exclusivity, the
4
+ * one-a-second `stampPoll` and a row's round trip through `patch` and
5
+ * `remove`. Run it against any store, the Postgres one included, with a
6
+ * function that makes a fresh store over an empty table (a store with
7
+ * `capacity` of at least 4 rows and the default poll gap). It throws an Error
8
+ * naming the broken rule, and uses no test framework.
9
+ *
10
+ * It does not check the store's total capacity, which is the store's own.
11
+ */
12
+ export async function checkDesktopHandoffStore(create) {
13
+ const ensure = (ok, rule) => {
14
+ if (!ok)
15
+ throw new Error(`DesktopHandoffStore contract broken: ${rule}`);
16
+ };
17
+ let n = 0;
18
+ const row = (callerHash) => {
19
+ n += 1;
20
+ const id = `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
21
+ return {
22
+ id,
23
+ challenge: `c${n}`.padEnd(43, 'x'),
24
+ browserHash: `b${n}`,
25
+ stateHash: `s${n}`,
26
+ callerHash,
27
+ };
28
+ };
29
+ // admit: the cap is per caller, and callers with no address share one bucket.
30
+ const capped = await create();
31
+ const limits = { perCaller: 2 };
32
+ ensure(await capped.admit(row('a'), limits), 'admit refused the first start of a caller');
33
+ ensure(await capped.admit(row('a'), limits), 'admit refused a start below perCaller');
34
+ ensure(!(await capped.admit(row('a'), limits)), 'admit ignored the per-caller cap');
35
+ ensure(await capped.admit(row('b'), limits), 'admit let one caller fill the store for another');
36
+ ensure(await capped.admit(row(null), limits), 'admit refused the first start with no address');
37
+ ensure(await capped.admit(row(null), limits), 'admit refused a null-caller start below perCaller');
38
+ ensure(!(await capped.admit(row(null), limits)), 'null callers do not share one bucket');
39
+ // admit: a removed row frees its slot.
40
+ const mine = row('c');
41
+ const slots = await create();
42
+ ensure(await slots.admit(mine, { perCaller: 1 }), 'admit refused a first start');
43
+ ensure(!(await slots.admit(row('c'), { perCaller: 1 })), 'admit ignored a cap of 1');
44
+ await slots.transaction((tx) => slots.remove(tx, mine.id));
45
+ ensure(await slots.admit(row('c'), { perCaller: 1 }), 'remove did not free the caller slot');
46
+ // lock: a second caller waits until the first transaction ends.
47
+ const locked = await create();
48
+ const target = row('d');
49
+ ensure(await locked.admit(target, { perCaller: 8 }), 'admit refused a first start');
50
+ const order = [];
51
+ let holding;
52
+ const held = new Promise((resolve) => {
53
+ holding = resolve;
54
+ });
55
+ const first = locked.transaction(async (tx) => {
56
+ ensure((await locked.lock(tx, target.id)) !== undefined, 'lock did not find the row');
57
+ holding();
58
+ await new Promise((resolve) => setTimeout(resolve, 50));
59
+ order.push('first ends');
60
+ });
61
+ await held;
62
+ const second = locked.transaction(async (tx) => {
63
+ await locked.lock(tx, target.id);
64
+ order.push('second locked');
65
+ });
66
+ await Promise.all([first, second]);
67
+ ensure(order.join() === 'first ends,second locked', 'lock is not exclusive: a second caller got the row while the first transaction was open');
68
+ // stampPoll: one stamp a gap, answered true then false.
69
+ const polled = await create();
70
+ const stamped = row('e');
71
+ ensure(await polled.admit(stamped, { perCaller: 8 }), 'admit refused a first start');
72
+ const stamps = await polled.transaction(async (tx) => [
73
+ await polled.stampPoll(tx, stamped.id),
74
+ await polled.stampPoll(tx, stamped.id),
75
+ ]);
76
+ ensure(stamps[0] === true, 'stampPoll refused the first attempt on a row');
77
+ ensure(stamps[1] === false, 'stampPoll allowed a second attempt inside the gap');
78
+ // patch and read: a row keeps what was written and reads back as its status says.
79
+ const patched = await create();
80
+ const target2 = row('f');
81
+ ensure(await patched.admit(target2, { perCaller: 8 }), 'admit refused a first start');
82
+ const started = await patched.read(target2.id);
83
+ ensure(started?.row.status === 'started' && started.row.payload === null && started.live, 'a new row did not read back as a live started row');
84
+ const revision = '11111111-1111-4111-8111-111111111111';
85
+ await patched.transaction(async (tx) => {
86
+ await patched.patch(tx, target2.id, {
87
+ status: 'completed',
88
+ payload: 'sealed',
89
+ subject: 'user-1',
90
+ email: null,
91
+ revision,
92
+ });
93
+ await patched.patch(tx, target2.id, { status: 'approved' });
94
+ });
95
+ const approved = (await patched.read(target2.id))?.row;
96
+ ensure(approved?.status === 'approved' &&
97
+ approved.payload === 'sealed' &&
98
+ approved.subject === 'user-1' &&
99
+ approved.email === null &&
100
+ approved.revision === revision &&
101
+ approved.challenge === target2.challenge &&
102
+ approved.browserHash === target2.browserHash &&
103
+ approved.stateHash === target2.stateHash, 'patch did not keep what it wrote');
104
+ await patched.transaction((tx) => patched.remove(tx, target2.id));
105
+ ensure((await patched.read(target2.id)) === undefined, 'remove left the row');
106
+ }
@@ -0,0 +1,40 @@
1
+ import type { DesktopHandoffStore } from './desktop-store.js';
2
+ /** Runs one statement; `$1`, `$2`... in `text` are `values`. Resolves to the rows, empty for a statement that returns none. */
3
+ export interface DesktopHandoffQuery {
4
+ query(text: string, values: readonly unknown[]): Promise<readonly Record<string, unknown>[]>;
5
+ }
6
+ /**
7
+ * What the app gives `createPostgresDesktopHandoffStore`. `transaction` runs
8
+ * `fn` on one connection inside one transaction: it commits when `fn` returns
9
+ * and rolls back when `fn` throws. The `tx` it hands over runs its statements
10
+ * in that transaction.
11
+ */
12
+ export interface DesktopHandoffSqlClient extends DesktopHandoffQuery {
13
+ transaction<T>(fn: (tx: DesktopHandoffQuery) => Promise<T>): Promise<T>;
14
+ }
15
+ export interface PostgresDesktopHandoffStoreOptions {
16
+ /** At most this many unexpired rows exist at once; a start beyond it is refused. Defaults to 128. */
17
+ capacity?: number;
18
+ /** How long a row lives after `admit`. Defaults to 5 minutes. */
19
+ ttlMs?: number;
20
+ /** The minimum gap `stampPoll` allows between two redeem attempts on the same row. Defaults to 1 second. */
21
+ pollGapMs?: number;
22
+ }
23
+ /**
24
+ * The table, for a new app to run once in its migrations. Boule's and
25
+ * Archon's tables are this table already, except that Archon's lacks
26
+ * `caller_hash` (`ALTER TABLE desktop_handoffs ADD COLUMN IF NOT EXISTS
27
+ * caller_hash text`).
28
+ *
29
+ * The CHECK on `payload` keeps a sealed cookie from outliving `completed` and
30
+ * `approved`, so a cancelled, redeemed or abandoned row never holds a
31
+ * credential. The runtime role needs SELECT, INSERT, UPDATE and DELETE.
32
+ */
33
+ export declare const DESKTOP_HANDOFFS_TABLE_SQL = "CREATE TABLE IF NOT EXISTS desktop_handoffs (\n id uuid PRIMARY KEY,\n challenge text NOT NULL CHECK (length(challenge) = 43),\n browser_hash text,\n state_hash text,\n caller_hash text,\n status text NOT NULL CHECK (status IN ('started', 'completed', 'approved', 'cancelled', 'redeemed')),\n payload text,\n subject text,\n email text,\n revision uuid,\n created_at timestamptz NOT NULL DEFAULT clock_timestamp(),\n expires_at timestamptz NOT NULL DEFAULT clock_timestamp() + interval '5 minutes',\n last_poll timestamptz,\n CONSTRAINT desktop_handoffs_payload_check\n CHECK ((status IN ('completed', 'approved')) = (payload IS NOT NULL))\n);\n\nCREATE INDEX IF NOT EXISTS desktop_handoffs_expires_at_idx ON desktop_handoffs (expires_at);\n";
34
+ /**
35
+ * Prunes, counts and inserts under one advisory lock, so two starts cannot both
36
+ * take the last slot of the total or of a caller's bucket. `lock` is
37
+ * `SELECT ... FOR UPDATE`, held to the end of the transaction; `live` and the
38
+ * poll gap come from the database's clock, read after any wait for a lock.
39
+ */
40
+ export declare function createPostgresDesktopHandoffStore(client: DesktopHandoffSqlClient, options?: PostgresDesktopHandoffStoreOptions): DesktopHandoffStore;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The table, for a new app to run once in its migrations. Boule's and
3
+ * Archon's tables are this table already, except that Archon's lacks
4
+ * `caller_hash` (`ALTER TABLE desktop_handoffs ADD COLUMN IF NOT EXISTS
5
+ * caller_hash text`).
6
+ *
7
+ * The CHECK on `payload` keeps a sealed cookie from outliving `completed` and
8
+ * `approved`, so a cancelled, redeemed or abandoned row never holds a
9
+ * credential. The runtime role needs SELECT, INSERT, UPDATE and DELETE.
10
+ */
11
+ export const DESKTOP_HANDOFFS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS desktop_handoffs (
12
+ id uuid PRIMARY KEY,
13
+ challenge text NOT NULL CHECK (length(challenge) = 43),
14
+ browser_hash text,
15
+ state_hash text,
16
+ caller_hash text,
17
+ status text NOT NULL CHECK (status IN ('started', 'completed', 'approved', 'cancelled', 'redeemed')),
18
+ payload text,
19
+ subject text,
20
+ email text,
21
+ revision uuid,
22
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
23
+ expires_at timestamptz NOT NULL DEFAULT clock_timestamp() + interval '5 minutes',
24
+ last_poll timestamptz,
25
+ CONSTRAINT desktop_handoffs_payload_check
26
+ CHECK ((status IN ('completed', 'approved')) = (payload IS NOT NULL))
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS desktop_handoffs_expires_at_idx ON desktop_handoffs (expires_at);
30
+ `;
31
+ /** The key Boule's and Archon's own stores took, so an app mid-cutover still serialises against its old build. */
32
+ const ADMIT_LOCK = 7400194;
33
+ const COLUMNS = `id, status, challenge, browser_hash, state_hash, payload, subject, email, revision,
34
+ expires_at > clock_timestamp() AS live`;
35
+ const positive = (name, value) => {
36
+ if (!Number.isFinite(value) || value < 1)
37
+ throw new Error(`createPostgresDesktopHandoffStore: ${name} must be a positive number`);
38
+ return value;
39
+ };
40
+ const text = (value) => (typeof value === 'string' ? value : null);
41
+ /**
42
+ * The row as the handler's types promise it. A row the handler never writes (a
43
+ * status outside the three, or a result missing from a `completed` row) is
44
+ * refused by name; no column value goes into the message.
45
+ */
46
+ function found(record) {
47
+ if (!record)
48
+ return undefined;
49
+ const id = text(record.id);
50
+ const challenge = text(record.challenge);
51
+ const browserHash = text(record.browser_hash);
52
+ const stateHash = text(record.state_hash);
53
+ if (id === null || challenge === null || browserHash === null || stateHash === null)
54
+ throw new Error('desktop_handoffs row is missing a column the handler writes at start');
55
+ const base = { id, challenge, browserHash, stateHash };
56
+ const status = record.status;
57
+ let row;
58
+ if (status === 'started') {
59
+ row = { ...base, status, payload: null, subject: null, email: null, revision: null };
60
+ }
61
+ else if (status === 'completed' || status === 'approved') {
62
+ const payload = text(record.payload);
63
+ const subject = text(record.subject);
64
+ const revision = text(record.revision);
65
+ if (payload === null || subject === null || revision === null)
66
+ throw new Error('desktop_handoffs row has no result for its status');
67
+ row = { ...base, status, payload, subject, email: text(record.email), revision };
68
+ }
69
+ else {
70
+ throw new Error('desktop_handoffs row has a status the handler does not write');
71
+ }
72
+ return { row, live: record.live === true };
73
+ }
74
+ /**
75
+ * Prunes, counts and inserts under one advisory lock, so two starts cannot both
76
+ * take the last slot of the total or of a caller's bucket. `lock` is
77
+ * `SELECT ... FOR UPDATE`, held to the end of the transaction; `live` and the
78
+ * poll gap come from the database's clock, read after any wait for a lock.
79
+ */
80
+ export function createPostgresDesktopHandoffStore(client, options = {}) {
81
+ const capacity = positive('capacity', options.capacity ?? 128);
82
+ const ttlMs = positive('ttlMs', options.ttlMs ?? 5 * 60 * 1000);
83
+ const pollGapMs = options.pollGapMs ?? 1000;
84
+ if (!Number.isFinite(pollGapMs))
85
+ throw new Error('createPostgresDesktopHandoffStore: pollGapMs must be a number');
86
+ /** `tx` is what `client.transaction` handed `fn`, passed back through the handler. */
87
+ const run = (tx, sql, values) => tx.query(sql, values);
88
+ return {
89
+ admit: (row, limits) => client.transaction(async (tx) => {
90
+ await tx.query('SELECT pg_advisory_xact_lock($1)', [ADMIT_LOCK]);
91
+ await tx.query('DELETE FROM desktop_handoffs WHERE expires_at <= clock_timestamp()', []);
92
+ const [counts] = await tx.query(`SELECT count(*)::int AS live,
93
+ (count(*) FILTER (WHERE caller_hash IS NOT DISTINCT FROM $1))::int AS mine
94
+ FROM desktop_handoffs`, [row.callerHash]);
95
+ if (Number(counts?.live) >= capacity || Number(counts?.mine) >= limits.perCaller)
96
+ return false;
97
+ await tx.query(`INSERT INTO desktop_handoffs
98
+ (id, challenge, browser_hash, state_hash, caller_hash, status, expires_at)
99
+ VALUES ($1, $2, $3, $4, $5, 'started', clock_timestamp() + $6 * interval '1 millisecond')`, [row.id, row.challenge, row.browserHash, row.stateHash, row.callerHash, ttlMs]);
100
+ return true;
101
+ }),
102
+ transaction: (fn) => client.transaction((tx) => fn(tx)),
103
+ async lock(tx, id) {
104
+ const [record] = await run(tx, `SELECT ${COLUMNS} FROM desktop_handoffs WHERE id = $1 FOR UPDATE`, [id]);
105
+ return found(record);
106
+ },
107
+ async read(id) {
108
+ const [record] = await client.query(`SELECT ${COLUMNS} FROM desktop_handoffs WHERE id = $1`, [
109
+ id,
110
+ ]);
111
+ return found(record);
112
+ },
113
+ async patch(tx, id, set) {
114
+ if (set.status === 'approved') {
115
+ await run(tx, 'UPDATE desktop_handoffs SET status = $2 WHERE id = $1', [id, set.status]);
116
+ return;
117
+ }
118
+ await run(tx, `UPDATE desktop_handoffs
119
+ SET status = $2, payload = $3, subject = $4, email = $5, revision = $6
120
+ WHERE id = $1`, [id, set.status, set.payload, set.subject, set.email, set.revision]);
121
+ },
122
+ async remove(tx, id) {
123
+ await run(tx, 'DELETE FROM desktop_handoffs WHERE id = $1', [id]);
124
+ },
125
+ async stampPoll(tx, id) {
126
+ const stamped = await run(tx, `UPDATE desktop_handoffs SET last_poll = clock_timestamp()
127
+ WHERE id = $1
128
+ AND (last_poll IS NULL OR last_poll < clock_timestamp() - $2 * interval '1 millisecond')
129
+ RETURNING id`, [id, pollGapMs]);
130
+ return stamped.length === 1;
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,130 @@
1
+ export type DesktopHandoffStatus = 'started' | 'completed' | 'approved';
2
+ interface DesktopHandoffBase {
3
+ id: string;
4
+ challenge: string;
5
+ /** Digest of the browser nonce; `admit` always writes it. */
6
+ browserHash: string;
7
+ /** Digest of the issuer `state`; `admit` always writes it. */
8
+ stateHash: string;
9
+ }
10
+ /**
11
+ * A row by its status. `started` holds no result yet; `completed` and
12
+ * `approved` hold the sealed session cookie and who it is for, so reading
13
+ * `status` proves the fields.
14
+ */
15
+ export type DesktopHandoffRow = (DesktopHandoffBase & {
16
+ status: 'started';
17
+ payload: null;
18
+ subject: null;
19
+ email: null;
20
+ revision: null;
21
+ }) | (DesktopHandoffBase & {
22
+ status: 'completed' | 'approved';
23
+ payload: string;
24
+ subject: string;
25
+ email: string | null;
26
+ revision: string;
27
+ });
28
+ /** What `patch` writes: the callback's result, or the person's confirmation. */
29
+ export type DesktopHandoffPatch = {
30
+ status: 'completed';
31
+ payload: string;
32
+ subject: string;
33
+ email: string | null;
34
+ revision: string;
35
+ } | {
36
+ status: 'approved';
37
+ };
38
+ export interface DesktopHandoffFound {
39
+ row: DesktopHandoffRow;
40
+ /** The store's own clock decides this, read after any wait for a lock. */
41
+ live: boolean;
42
+ }
43
+ /**
44
+ * What `transaction` hands its function, and the other methods take back. It
45
+ * is the store's own and opaque to the handler.
46
+ */
47
+ export type DesktopHandoffTx = object;
48
+ /**
49
+ * The handoff's row store. `createPostgresDesktopHandoffStore` is the shared
50
+ * one over Postgres; `createMemoryDesktopHandoffStore` serves one process. An
51
+ * app with another database implements this interface over its own table.
52
+ *
53
+ * A real store must make `lock` exclusive: inside `transaction`, `lock` is
54
+ * `SELECT ... FOR UPDATE` on the row, held until the transaction ends. The
55
+ * handler's one-use guarantee (a redeem returns the cookie once, a callback
56
+ * admits once) rests on two concurrent callers on one row taking turns. A store
57
+ * whose `lock` is a plain read lets both pass, and both get the cookie.
58
+ *
59
+ * `admit` carries the per-caller cap the same way (see its contract). Run
60
+ * `checkDesktopHandoffStore` against any store in its tests.
61
+ */
62
+ export interface DesktopHandoffStore {
63
+ /**
64
+ * Prunes expired rows, then inserts a `started` row if there is room, and
65
+ * answers whether it did. The contract, which the handler relies on and
66
+ * cannot check (`checkDesktopHandoffStore` does):
67
+ *
68
+ * - Per-caller cap: it inserts only when fewer than `limits.perCaller`
69
+ * unexpired rows already carry the same `callerHash`. `callerHash` is a
70
+ * digest of the caller's address, null when the request carried none; null
71
+ * callers share one bucket. The cap is what stops one address filling the
72
+ * store (the unauthenticated `/start` is otherwise a capacity DoS), so a
73
+ * store that ignores `limits` fails open.
74
+ * - Total capacity: it refuses when the store's own total of unexpired rows
75
+ * is at its capacity, whoever asks.
76
+ * - Atomic: the count and the insert are one step. In SQL that is one
77
+ * transaction that first takes a lock that serialises admissions (for
78
+ * example `SELECT ... FOR UPDATE` on the rows of this `callerHash`, or an
79
+ * advisory lock), so two concurrent starts cannot both pass at
80
+ * `perCaller - 1`.
81
+ * - False means refused; it never throws for a full store.
82
+ */
83
+ admit(row: {
84
+ id: string;
85
+ challenge: string;
86
+ browserHash: string;
87
+ stateHash: string;
88
+ callerHash: string | null;
89
+ }, limits: {
90
+ perCaller: number;
91
+ }): Promise<boolean>;
92
+ /**
93
+ * Runs `fn` in one database transaction: it commits when `fn` returns and
94
+ * rolls back when `fn` throws. The handler depends on both halves. A redeem
95
+ * that refuses after stamping the attempt (`stampPoll`) or deleting an expired
96
+ * row RETURNS its error from `fn` instead of throwing, so the stamp and the
97
+ * delete commit; the handler throws it only after the transaction ended. A
98
+ * store that commits on a throw loses nothing but lets a half-done step
99
+ * persist; a store that rolls back on a return lets a guesser skip the
100
+ * one-a-second limit. (The memory store has no rollback: its writes apply at
101
+ * once, which is why the handler never relies on one.)
102
+ */
103
+ transaction<T>(fn: (tx: DesktopHandoffTx) => Promise<T>): Promise<T>;
104
+ /** The row, locked until the transaction ends (`SELECT ... FOR UPDATE`). A second caller waits for the first to finish. */
105
+ lock(tx: DesktopHandoffTx, id: string): Promise<DesktopHandoffFound | undefined>;
106
+ /** The row without a lock, for a page that only shows it. */
107
+ read(id: string): Promise<DesktopHandoffFound | undefined>;
108
+ patch(tx: DesktopHandoffTx, id: string, set: DesktopHandoffPatch): Promise<void>;
109
+ /** Deletes the row: a sign-in that ended holds no payload, email or subject, and frees its slot. */
110
+ remove(tx: DesktopHandoffTx, id: string): Promise<void>;
111
+ /**
112
+ * Stamps a redeem attempt if the last one is at least the store's poll gap
113
+ * (one second) old, by the store's own clock, and answers whether it did.
114
+ * False means too soon: nothing was written.
115
+ */
116
+ stampPoll(tx: DesktopHandoffTx, id: string): Promise<boolean>;
117
+ }
118
+ /**
119
+ * One process only. For tests and a single-instance development server: two
120
+ * replicas with separate memory stores do not share rows.
121
+ */
122
+ export declare function createMemoryDesktopHandoffStore(options?: {
123
+ /** At most this many unexpired rows exist at once; a start beyond it is refused. Defaults to 128. */
124
+ capacity?: number;
125
+ /** How long a row lives after `admit`. Defaults to 5 minutes. */
126
+ ttlMs?: number;
127
+ /** The minimum gap `stampPoll` allows between two redeem attempts on the same row. Defaults to 1 second. */
128
+ pollGapMs?: number;
129
+ }): DesktopHandoffStore;
130
+ export {};