@wtfalch/auth 0.32.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.
@@ -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 {};
@@ -0,0 +1,111 @@
1
+ /*
2
+ * The handoff's row store: the interface a store implements over a table, and a
3
+ * memory implementation for one process. `createPostgresDesktopHandoffStore`
4
+ * (desktop-store-postgres.ts) is the shared Postgres one.
5
+ * `checkDesktopHandoffStore` (desktop-store-contract.ts) runs the contract below
6
+ * against any store.
7
+ */
8
+ /**
9
+ * One process only. For tests and a single-instance development server: two
10
+ * replicas with separate memory stores do not share rows.
11
+ */
12
+ export function createMemoryDesktopHandoffStore(options) {
13
+ const capacity = options?.capacity ?? 128;
14
+ const ttlMs = options?.ttlMs ?? 5 * 60 * 1000;
15
+ const pollGapMs = options?.pollGapMs ?? 1000;
16
+ const rows = new Map();
17
+ /** Per row id, the end of the line of transactions waiting for it. */
18
+ const lineEnds = new Map();
19
+ async function acquire(tx, id) {
20
+ if (tx.held.has(id))
21
+ return;
22
+ const before = lineEnds.get(id) ?? Promise.resolve();
23
+ let release;
24
+ const turn = new Promise((resolve) => {
25
+ release = resolve;
26
+ });
27
+ const end = before.then(() => turn);
28
+ lineEnds.set(id, end);
29
+ await before;
30
+ tx.held.set(id, () => {
31
+ if (lineEnds.get(id) === end)
32
+ lineEnds.delete(id);
33
+ release();
34
+ });
35
+ }
36
+ const sweep = () => {
37
+ const now = Date.now();
38
+ for (const [id, entry] of rows)
39
+ if (entry.expiresAt <= now)
40
+ rows.delete(id);
41
+ };
42
+ const found = (entry) => {
43
+ const { expiresAt, lastPoll: _poll, callerHash: _caller, ...row } = entry;
44
+ return { row, live: expiresAt > Date.now() };
45
+ };
46
+ return {
47
+ async admit(row, limits) {
48
+ sweep();
49
+ if (rows.size >= capacity)
50
+ return false;
51
+ let mine = 0;
52
+ for (const entry of rows.values())
53
+ if (entry.callerHash === row.callerHash)
54
+ mine++;
55
+ if (mine >= limits.perCaller)
56
+ return false;
57
+ rows.set(row.id, {
58
+ id: row.id,
59
+ status: 'started',
60
+ challenge: row.challenge,
61
+ browserHash: row.browserHash,
62
+ stateHash: row.stateHash,
63
+ payload: null,
64
+ subject: null,
65
+ email: null,
66
+ revision: null,
67
+ expiresAt: Date.now() + ttlMs,
68
+ lastPoll: null,
69
+ callerHash: row.callerHash,
70
+ });
71
+ return true;
72
+ },
73
+ async transaction(fn) {
74
+ const tx = { held: new Map() };
75
+ try {
76
+ return await fn(tx);
77
+ }
78
+ finally {
79
+ for (const release of tx.held.values())
80
+ release();
81
+ }
82
+ },
83
+ async lock(tx, id) {
84
+ await acquire(tx, id);
85
+ const entry = rows.get(id);
86
+ return entry ? found(entry) : undefined;
87
+ },
88
+ async read(id) {
89
+ const entry = rows.get(id);
90
+ return entry ? found(entry) : undefined;
91
+ },
92
+ async patch(_tx, id, set) {
93
+ const entry = rows.get(id);
94
+ if (entry)
95
+ Object.assign(entry, set);
96
+ },
97
+ async remove(_tx, id) {
98
+ rows.delete(id);
99
+ },
100
+ async stampPoll(_tx, id) {
101
+ const entry = rows.get(id);
102
+ if (!entry)
103
+ return false;
104
+ const now = Date.now();
105
+ if (entry.lastPoll !== null && entry.lastPoll >= now - pollGapMs)
106
+ return false;
107
+ entry.lastPoll = now;
108
+ return true;
109
+ },
110
+ };
111
+ }
package/dist/desktop.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import type { Auth, User } from './auth.js';
2
+ import type { DesktopHandoffStatus, DesktopHandoffStore } from './desktop-store.js';
3
+ export { type DesktopHandoffFound, type DesktopHandoffPatch, type DesktopHandoffRow, type DesktopHandoffStatus, type DesktopHandoffStore, type DesktopHandoffTx, createMemoryDesktopHandoffStore, } from './desktop-store.js';
4
+ export { DESKTOP_HANDOFFS_TABLE_SQL, type DesktopHandoffQuery, type DesktopHandoffSqlClient, type PostgresDesktopHandoffStoreOptions, createPostgresDesktopHandoffStore, } from './desktop-store-postgres.js';
5
+ export { checkDesktopHandoffStore } from './desktop-store-contract.js';
2
6
  /**
3
7
  * The native sign-in handoff for a desktop or mobile shell, mounted under
4
8
  * `/auth/desktop/*`. Merged from Boule's and Archon's own copies
@@ -29,8 +33,9 @@ import type { Auth, User } from './auth.js';
29
33
  *
30
34
  * The store is the app's: this package never connects to a database it does
31
35
  * not own. `createMemoryDesktopHandoffStore` is enough for a single-instance
32
- * server and for tests; a multi-replica app supplies its own, backed by
33
- * whatever table it migrates.
36
+ * server and for tests; a multi-replica app passes its Postgres client to
37
+ * `createPostgresDesktopHandoffStore` over the table it migrates
38
+ * (`DESKTOP_HANDOFFS_TABLE_SQL`).
34
39
  */
35
40
  export type DesktopHandoffAuth = Pick<Auth, 'sessionCookieName' | 'start' | 'complete' | 'readCookie'>;
36
41
  export declare class DesktopHandoffError extends Error {
@@ -38,75 +43,6 @@ export declare class DesktopHandoffError extends Error {
38
43
  readonly code: string;
39
44
  constructor(status: number, code: string);
40
45
  }
41
- export type DesktopHandoffStatus = 'started' | 'completed' | 'approved';
42
- export interface DesktopHandoffRow {
43
- id: string;
44
- status: DesktopHandoffStatus;
45
- challenge: string;
46
- browserHash: string | null;
47
- stateHash: string | null;
48
- payload: string | null;
49
- subject: string | null;
50
- email: string | null;
51
- revision: string | null;
52
- }
53
- export interface DesktopHandoffFound {
54
- row: DesktopHandoffRow;
55
- /** The store's own clock decides this, read after any wait for a lock. */
56
- live: boolean;
57
- /** Whether a redeem attempt may be stamped now: at most one a second per row. */
58
- pollOk: boolean;
59
- }
60
- /**
61
- * The handoff's row store. The app's own, not this package's: an app
62
- * migrates one table and implements this interface over it.
63
- *
64
- * A real store must make `lock` exclusive: inside `transaction`, `lock` is
65
- * `SELECT ... FOR UPDATE` on the row, held until the transaction ends. The
66
- * handler's one-use guarantee (a redeem returns the cookie once, a callback
67
- * admits once) rests on two concurrent callers on one row taking turns. A store
68
- * whose `lock` is a plain read lets both pass, and both get the cookie.
69
- */
70
- export interface DesktopHandoffStore<Tx = unknown> {
71
- /**
72
- * Prunes expired rows, then inserts a `started` row if there is room: fewer
73
- * than `perCaller` unexpired rows with the same `callerHash`, and the store's
74
- * own total below its capacity. False when either limit is reached.
75
- * `callerHash` is a digest of the caller's address, null when the request
76
- * carried none; null callers share one bucket.
77
- */
78
- admit(row: {
79
- id: string;
80
- challenge: string;
81
- browserHash: string;
82
- stateHash: string;
83
- callerHash: string | null;
84
- }, limits: {
85
- perCaller: number;
86
- }): Promise<boolean>;
87
- transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
88
- /** The row, locked until the transaction ends (`SELECT ... FOR UPDATE`). A second caller waits for the first to finish. */
89
- lock(tx: Tx, id: string): Promise<DesktopHandoffFound | undefined>;
90
- /** The row without a lock, for a page that only shows it. */
91
- read(id: string): Promise<DesktopHandoffFound | undefined>;
92
- patch(tx: Tx, id: string, set: Partial<DesktopHandoffRow>): Promise<void>;
93
- /** Deletes the row: a sign-in that ended holds no payload, email or subject, and frees its slot. */
94
- remove(tx: Tx, id: string): Promise<void>;
95
- /** Stamps a redeem attempt, which `pollOk` reads. */
96
- polled(tx: Tx, id: string): Promise<void>;
97
- }
98
- /**
99
- * One process only. For tests and a single-instance development server: two
100
- * replicas with separate memory stores do not share rows.
101
- */
102
- export declare function createMemoryDesktopHandoffStore(options?: {
103
- /** At most this many unexpired rows exist at once; a start beyond it is refused. Defaults to 128. */
104
- capacity?: number;
105
- /** How long a row lives after `admit`. Defaults to 5 minutes. */
106
- ttlMs?: number;
107
- /** The minimum gap `pollOk` allows between two redeem attempts on the same row. Defaults to 1 second. */
108
- pollGapMs?: number;
109
- }): DesktopHandoffStore;
110
46
  /**
111
47
  * What the admission hook answers before a session is handed to the shell.
112
48
  * Boule's and Archon's copies differed here: Boule handed over whoever the
@@ -139,7 +75,7 @@ export interface DesktopHandoffCurrent {
139
75
  */
140
76
  returnUrl: string | null;
141
77
  }
142
- export interface DesktopHandoffConfig<Tx = unknown> {
78
+ export interface DesktopHandoffConfig {
143
79
  /** The SDK instance the browser side signs in with: a second `createAuth`, its `basePath` moved to `/auth/desktop`, registered at the issuer next to the app's own callback. */
144
80
  auth: DesktopHandoffAuth;
145
81
  /** The app's own origin. */
@@ -150,7 +86,7 @@ export interface DesktopHandoffConfig<Tx = unknown> {
150
86
  appId: string;
151
87
  /** The shell's own return scheme (`cookieHandoff({ scheme })` in `@wtfalch/desktop`, or a mobile shell's own); lowercase letters, digits, `+`, `.` and `-`, starting with a letter. The app's config, never the request, decides this. */
152
88
  scheme: string;
153
- store: DesktopHandoffStore<Tx>;
89
+ store: DesktopHandoffStore;
154
90
  /**
155
91
  * Defaults to `alwaysAdmit`, which admits whoever the issuer signed in,
156
92
  * unchanged (Boule's own behaviour). That is fail-open: an app that needs an
@@ -175,7 +111,7 @@ export interface DesktopHandoffErrorEvent {
175
111
  reason: string;
176
112
  errorName: string;
177
113
  }
178
- export declare function createDesktopHandoff<Tx = unknown>({ auth, appUrl, secret, appId, scheme, store, admit, onError, clientAddressHeader, maxStartsPerCaller, }: DesktopHandoffConfig<Tx>): {
114
+ export declare function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, store, admit, onError, clientAddressHeader, maxStartsPerCaller, }: DesktopHandoffConfig): {
179
115
  /** The cookie name, for the one place a test or a route needs to read it. */
180
116
  cookieName: string;
181
117
  /** GET /auth/desktop/start */
@@ -194,6 +130,5 @@ export declare function createDesktopHandoff<Tx = unknown>({ auth, appUrl, secre
194
130
  cancel: (request: Request) => Promise<Response>;
195
131
  /** POST /auth/desktop/redeem, with the body still unread: refuses one that is not small and declared. */
196
132
  redeemRequest(request: Request): Promise<Response>;
197
- /** The shell trades its verifier for the cookie, once. No browser cookie involved. */
198
- redeem(input: unknown): Promise<Response>;
133
+ redeem: (input: unknown) => Promise<Response>;
199
134
  };