@wtfalch/auth 0.31.0 → 0.33.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/README.md CHANGED
@@ -293,9 +293,43 @@ at `/auth/desktop/redeem`.
293
293
  whoever the issuer signed in, with no further check. It fails open: an app
294
294
  that needs an organisation, second-factor or linked-principal check and leaves
295
295
  `admit` out gets no warning. Pass the check explicitly.
296
+ - **The store.** A multi-replica app passes its Postgres client to
297
+ `createPostgresDesktopHandoffStore(client, { capacity, ttlMs, pollGapMs })`
298
+ and runs `DESKTOP_HANDOFFS_TABLE_SQL` once in a migration. The client is two
299
+ methods, so any driver fits: `query(text, values)` resolves to the rows, and
300
+ `transaction(fn)` runs `fn` with the same `query` shape on one connection,
301
+ committing when `fn` returns and rolling back when it throws. With `pg`:
302
+
303
+ ```ts
304
+ const client: DesktopHandoffSqlClient = {
305
+ query: async (text, values) => (await pool.query(text, [...values])).rows,
306
+ async transaction(fn) {
307
+ const connection = await pool.connect();
308
+ try {
309
+ await connection.query('BEGIN');
310
+ const result = await fn({
311
+ query: async (text, values) => (await connection.query(text, [...values])).rows,
312
+ });
313
+ await connection.query('COMMIT');
314
+ return result;
315
+ } catch (error) {
316
+ await connection.query('ROLLBACK');
317
+ throw error;
318
+ } finally {
319
+ connection.release();
320
+ }
321
+ },
322
+ };
323
+ ```
324
+
325
+ The table is Boule's and Archon's `desktop_handoffs` with a `caller_hash text`
326
+ column; an app that has the table without it runs
327
+ `ALTER TABLE desktop_handoffs ADD COLUMN IF NOT EXISTS caller_hash text`. Test
328
+ any store, this one included, with `checkDesktopHandoffStore`.
296
329
  - **The store must lock.** `lock(tx, id)` is `SELECT ... FOR UPDATE` in a real
297
330
  store. The memory store locks per row; a store that only reads lets two
298
- concurrent redeems both get the cookie.
331
+ concurrent redeems both get the cookie. `createMemoryDesktopHandoffStore`
332
+ serves one process only.
299
333
  - **Rate-limit `/auth/desktop/start` at the edge too.** The handler caps open
300
334
  sign-ins per caller (`maxStartsPerCaller`, default 8, keyed on the address in
301
335
  `clientAddressHeader`, default `cf-connecting-ip`) under the store's total
@@ -433,6 +467,74 @@ A phone that is already signed in can also approve a new device from a QR
433
467
  code, the issuer's OAuth device grant opted into per namespace binding with
434
468
  `qrSignIn: true`, through `startQrSignIn` and `pollQrSignIn`.
435
469
 
470
+ ## Workspaces for your app's customers
471
+
472
+ An app with `workspaces` on its entry gets `WORKSPACES_URL`, `WORKSPACES_API_KEY`
473
+ and `AUTH_ORGANIZATION_ID`. `@wtfalch/auth/workspaces` calls the broker with them.
474
+ The app keeps the current workspace id itself; this one holds it in a cookie.
475
+
476
+ ```ts
477
+ import { cookies } from 'next/headers';
478
+ import { workspaces, WorkspacesError } from '@wtfalch/auth/workspaces';
479
+ import { auth } from './auth'; // your createAuth() instance
480
+
481
+ const ws = workspaces(); // throws at once if an env value is missing
482
+
483
+ export async function currentWorkspace(request: Request) {
484
+ const { user } = await auth.read(request);
485
+ if (!user) return null;
486
+ const id = (await cookies()).get('workspace')?.value;
487
+ const role = id ? await ws.access(id, user) : null; // never trust the cookie alone
488
+ return id && role ? { id, role } : null;
489
+ }
490
+
491
+ export async function createWorkspace(request: Request, name: string) {
492
+ const { user } = await auth.read(request);
493
+ if (!user) throw new Error('Sign in first.');
494
+ const made = await ws.create({ name, owner: user });
495
+ (await cookies()).set('workspace', made.id, { httpOnly: true, sameSite: 'lax', secure: true });
496
+ return made;
497
+ }
498
+
499
+ export async function inviteBy(request: Request, id: string, email: string) {
500
+ const { user } = await auth.read(request);
501
+ if (!user) throw new Error('Sign in first.');
502
+ try {
503
+ await ws.invite(id, { email, role: 'member', actor: user }); // the broker mails the link
504
+ } catch (error) {
505
+ if (error instanceof WorkspacesError && error.status === 403) return 'Only an owner can invite.';
506
+ throw error;
507
+ }
508
+ }
509
+ // On the page behind `inviteUrl?token=`: await ws.accept(token, user) -> { workspaceId, role }
510
+
511
+ // Any member: who is in the workspace -> [{ sub, email, name, role }] (name is null; email is '' only for a creator from before this existed)
512
+ await ws.members(id, user);
513
+ // Owner only:
514
+ await ws.invitations(id, user); // pending -> [{ id, email, role, createdAt, expiresAt }]
515
+ await ws.revokeInvitation(id, invitationId, user); // the emailed link stops working
516
+ await ws.rename(id, 'New name', user);
517
+ await ws.archive(id, user); // leaves listFor; every later call on it fails with status 410
518
+ ```
519
+
520
+ ## Preview routes
521
+
522
+ For apps on live/idle releases. The balancer sends a request with the `bg_preview=1` cookie to the idle slot, after asking the live slot whether the person may see it.
523
+
524
+ ```ts
525
+ import { createPreviewRoutes } from '@wtfalch/auth';
526
+
527
+ export const preview = createPreviewRoutes({
528
+ auth, // the app's own `createAuth(...)` instance
529
+ mayPreview: (user) => canPreview(user), // `releases:preview` in the app's production authz
530
+ });
531
+ // GET /api/foundry/preview-check -> preview.check (200, 401 no session, 403 otherwise)
532
+ // POST /api/foundry/preview -> preview.set (204 and the cookie, same-origin only)
533
+ // DELETE /api/foundry/preview -> preview.clear (204, same-origin only, no permission needed)
534
+ ```
535
+
536
+ Answers carry no body and `cache-control: no-store`. A reader or permission check that throws answers 403 and logs one fixed line with the error name only. The session is read through `auth` (without refreshing), and the trusted origin and the cookie's `Secure` rule come from its `appUrl`, never from the request. They are read on the first request, so `next build` importing this module with no environment set does not throw. A request from another origin is refused with 403 and no log line. `PREVIEW_COOKIE`, `PREVIEW_CHECK_PATH` and `PREVIEW_PATH` are exported too.
537
+
436
538
  ## Documentation
437
539
 
438
540
  `docs/adopting.md` in [wtfalch/auth](https://github.com/wtfalch/auth) covers a
package/dist/auth.d.ts CHANGED
@@ -195,6 +195,10 @@ export interface QrPollResult {
195
195
  }
196
196
  export interface Auth {
197
197
  readonly sessionCookieName: string;
198
+ /** The app's own origin, from `appUrl`. Read on first use, so it throws only if `appUrl` is missing or malformed. */
199
+ readonly appOrigin: string;
200
+ /** Whether this app's cookies carry `Secure`: true when `appUrl` is https. The same rule the session cookie uses. */
201
+ readonly cookiesSecure: boolean;
198
202
  /** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
199
203
  start(request: Request): Promise<Response>;
200
204
  /** GET {basePath}/callback */
package/dist/auth.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import * as client from 'openid-client';
2
+ import { declaredWithin, readBounded } from './body.js';
2
3
  import { Broker, BrokerError } from './broker.js';
3
4
  import { resolveOptions, } from './config.js';
4
5
  import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openSilent, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealSilent, sealTransaction, sessionCookieName, silentCookieName, transactionCookieName, } from './cookies.js';
5
6
  import { namespaceSessions } from './namespace-session.js';
6
7
  import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, rejected, scopeFor, } from './oidc.js';
7
8
  import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
8
- import { safeLoginHint, safeNextPath } from './redirect.js';
9
- import { expiryOf, issuedAtOf, recheckSeconds, timeoutSignal } from './util.js';
9
+ import { isSameOrigin, safeLoginHint, safeNextPath } from './redirect.js';
10
+ import { NO_STORE, expiryOf, issuedAtOf, recheckSeconds, timeoutSignal } from './util.js';
10
11
  export function createAuth(input) {
11
12
  // Resolved on first use so `next build`, which imports every route module, needs none of the values set.
12
13
  let resolved = null;
@@ -142,11 +143,11 @@ export function createAuth(input) {
142
143
  const type = request.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
143
144
  if (type !== 'application/x-www-form-urlencoded')
144
145
  return new Response('Unsupported Media Type', { status: 415 });
145
- const length = request.headers.get('content-length');
146
- if (length === null || !/^\d{1,9}$/.test(length) || Number(length) > START_BODY_MAX)
146
+ if (!declaredWithin(request, START_BODY_MAX))
147
147
  return new Response('Payload Too Large', { status: 413 });
148
- const text = await request.text();
149
- if (text.length > START_BODY_MAX)
148
+ // The declared length is a hint; the read itself stops at the cap.
149
+ const text = await readBounded(request, START_BODY_MAX);
150
+ if (text === null)
150
151
  return new Response('Payload Too Large', { status: 413 });
151
152
  return new URLSearchParams(text);
152
153
  };
@@ -1032,6 +1033,12 @@ export function createAuth(input) {
1032
1033
  get sessionCookieName() {
1033
1034
  return sessionCookieName(options());
1034
1035
  },
1036
+ get appOrigin() {
1037
+ return options().appUrl.origin;
1038
+ },
1039
+ get cookiesSecure() {
1040
+ return options().secure;
1041
+ },
1035
1042
  start,
1036
1043
  callback,
1037
1044
  complete,
@@ -1087,38 +1094,8 @@ function redirect(location, cookies, status = 302) {
1087
1094
  headers.append('Set-Cookie', cookie.header);
1088
1095
  return new Response(null, { status, headers });
1089
1096
  }
1090
- const NO_STORE = { 'Cache-Control': 'no-store' };
1091
1097
  /** A logout token is a few hundred bytes; this is generous and still small. */
1092
1098
  const BACKCHANNEL_LOGOUT_MAX_BODY_BYTES = 16 * 1024;
1093
- /** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
1094
- async function readBounded(request, max) {
1095
- const declared = Number(request.headers.get('content-length'));
1096
- if (Number.isFinite(declared) && declared > max)
1097
- return null;
1098
- if (!request.body)
1099
- return '';
1100
- const reader = request.body.getReader();
1101
- const chunks = [];
1102
- let size = 0;
1103
- for (;;) {
1104
- const { done, value } = await reader.read();
1105
- if (done)
1106
- break;
1107
- size += value.byteLength;
1108
- if (size > max) {
1109
- await reader.cancel();
1110
- return null;
1111
- }
1112
- chunks.push(value);
1113
- }
1114
- const bytes = new Uint8Array(size);
1115
- let offset = 0;
1116
- for (const chunk of chunks) {
1117
- bytes.set(chunk, offset);
1118
- offset += chunk.byteLength;
1119
- }
1120
- return new TextDecoder().decode(bytes);
1121
- }
1122
1099
  /** A back-channel logout endpoint's 400 error body, per OpenID Connect Back-Channel Logout 1.0 §2.6. */
1123
1100
  function backchannelLogoutError(error, description, status = 400) {
1124
1101
  return new Response(JSON.stringify({ error, error_description: description }), {
@@ -1210,12 +1187,6 @@ function isNavigation(request) {
1210
1187
  return false;
1211
1188
  return !request.headers.has('rsc') && !request.headers.has('next-action');
1212
1189
  }
1213
- function isSameOrigin(request, origin) {
1214
- const from = request.headers.get('origin');
1215
- if (from)
1216
- return from === origin;
1217
- return request.headers.get('sec-fetch-site') === 'same-origin';
1218
- }
1219
1190
  // Never says whether the address exists, so neither can the app.
1220
1191
  async function quietly(act) {
1221
1192
  try {
package/dist/body.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Bounded request bodies: the one place the package decides how much of a request it will read. */
2
+ /** True when the request declares a plain decimal length of at most `max`. A missing or odd length is refused too. */
3
+ export declare function declaredWithin(request: Request, max: number): boolean;
4
+ /** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
5
+ export declare function readBounded(request: Request, max: number): Promise<string | null>;
package/dist/body.js ADDED
@@ -0,0 +1,35 @@
1
+ /** Bounded request bodies: the one place the package decides how much of a request it will read. */
2
+ /** True when the request declares a plain decimal length of at most `max`. A missing or odd length is refused too. */
3
+ export function declaredWithin(request, max) {
4
+ const length = request.headers.get('content-length');
5
+ return length !== null && /^\d{1,9}$/.test(length) && Number(length) <= max;
6
+ }
7
+ /** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
8
+ export async function readBounded(request, max) {
9
+ const declared = Number(request.headers.get('content-length'));
10
+ if (Number.isFinite(declared) && declared > max)
11
+ return null;
12
+ if (!request.body)
13
+ return '';
14
+ const reader = request.body.getReader();
15
+ const chunks = [];
16
+ let size = 0;
17
+ for (;;) {
18
+ const { done, value } = await reader.read();
19
+ if (done)
20
+ break;
21
+ size += value.byteLength;
22
+ if (size > max) {
23
+ await reader.cancel();
24
+ return null;
25
+ }
26
+ chunks.push(value);
27
+ }
28
+ const bytes = new Uint8Array(size);
29
+ let offset = 0;
30
+ for (const chunk of chunks) {
31
+ bytes.set(chunk, offset);
32
+ offset += chunk.byteLength;
33
+ }
34
+ return new TextDecoder().decode(bytes);
35
+ }
package/dist/cookies.d.ts CHANGED
@@ -78,6 +78,10 @@ export declare function linkCookieName(options: ResolvedOptions): string;
78
78
  export declare function sealLink(options: ResolvedOptions, payload: Omit<LinkPayload, keyof JWTPayload>): Promise<SetCookie>;
79
79
  export declare function openLink(options: ResolvedOptions, value: string | undefined): Promise<LinkPayload | null>;
80
80
  export declare function clearLink(options: ResolvedOptions): SetCookie;
81
+ /** The header that makes the browser drop the cookie `name`. */
82
+ export declare function clearCookie(name: string, secure: boolean): SetCookie;
83
+ /** Builds the header. A `maxAge` of 0 or less is the clearing form; callers clear through `clearCookie`. */
84
+ export declare function setCookie(name: string, value: string, maxAge: number, secure: boolean): SetCookie;
81
85
  export declare function cookieFrom(header: string | null, name: string): string | undefined;
82
86
  /** The Cookie header with these cookies replaced or removed, for a proxy to forward. */
83
87
  export declare function withCookies(header: string | null, updates: SetCookie[]): string;
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,7 +186,12 @@ async function open(options, kind, value) {
186
186
  return null;
187
187
  }
188
188
  }
189
- function setCookie(name, value, maxAge, secure) {
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`. */
194
+ export function setCookie(name, value, maxAge, secure) {
190
195
  const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
191
196
  if (maxAge > 0) {
192
197
  parts.push(`Max-Age=${maxAge}`);
@@ -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;