@rindle/api-server 0.3.1 → 0.4.3

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.js CHANGED
@@ -1,4 +1,10 @@
1
+ import { driveMutationAsync, insertCell, insertPlan, isoTx, toCell } from "@rindle/client";
1
2
  import { DaemonHttpError } from "@rindle/daemon-client";
3
+ import { compile as compileQueryAst } from "@rindle/query-compiler";
4
+ // Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
5
+ // co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
6
+ // keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
7
+ export { isoTx, shared } from "@rindle/client";
2
8
  export const DEFAULT_RINDLE_API_ROUTES = {
3
9
  query: "/api/rindle/query",
4
10
  read: "/api/rindle/read",
@@ -8,7 +14,79 @@ export const DEFAULT_RINDLE_API_ROUTES = {
8
14
  applyRowChangeTxn: "/api/rindle/apply-row-change-txn",
9
15
  claimRoomEpoch: "/api/rindle/claim-room-epoch",
10
16
  roomLmids: "/api/rindle/room-lmids",
17
+ // The DO shell's cold-boot callback (§10.1) — active only when `realtime` is configured.
18
+ roomBoot: "/api/rindle/room-boot",
11
19
  };
20
+ // The DEFAULT flush credential: `rfc1.<b64url payload>.<b64url hmac-sha256>`, payload
21
+ // `{v:1, doc, epoch, iat}`. Deliberately EPOCH-bound, not time-bound: the credential's lifecycle
22
+ // IS the placement fence (§8.3 — a superseded epoch's flushes 409 at the store no matter what
23
+ // credential they carry), and platform revocation is registry suspension, so an `exp` would only
24
+ // force spurious re-boots of long-lived rooms. HMAC via WebCrypto (`crypto.subtle`) so the exact
25
+ // same code runs in Node and a Cloudflare Worker — no `node:crypto` import (matching
26
+ // `@rindle/room`'s token module).
27
+ export const ROOM_FLUSH_CREDENTIAL_HEADER = "x-rindle-room-credential";
28
+ const FLUSH_CREDENTIAL_PREFIX = "rfc1";
29
+ function b64url(bytes) {
30
+ let bin = "";
31
+ for (const b of bytes)
32
+ bin += String.fromCharCode(b);
33
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
+ }
35
+ // Return type inferred (`Uint8Array<ArrayBuffer>` under TS ≥5.7 libs) — an explicit
36
+ // `Uint8Array` annotation widens to `ArrayBufferLike` and fails `crypto.subtle`'s
37
+ // `BufferSource` under consumers compiling this source with newer lib types.
38
+ function unb64url(s) {
39
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
40
+ const out = new Uint8Array(bin.length);
41
+ for (let i = 0; i < bin.length; i++)
42
+ out[i] = bin.charCodeAt(i);
43
+ return out;
44
+ }
45
+ async function flushHmacKey(secret, usage) {
46
+ return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
47
+ }
48
+ /** Sign the default epoch-bound flush credential (`/room-boot` mints one per placement). */
49
+ export async function mintRoomFlushCredential(opts) {
50
+ const payload = {
51
+ v: 1,
52
+ doc: opts.doc,
53
+ epoch: opts.epoch,
54
+ iat: opts.now ?? Date.now(),
55
+ };
56
+ const body = b64url(new TextEncoder().encode(JSON.stringify(payload)));
57
+ const signed = `${FLUSH_CREDENTIAL_PREFIX}.${body}`;
58
+ const key = await flushHmacKey(opts.shellSecret, "sign");
59
+ const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed)));
60
+ return `${signed}.${b64url(sig)}`;
61
+ }
62
+ /** Verify a flush credential's MAC, then its claims; returns the payload or throws. The MAC is
63
+ * checked FIRST — no claim is trusted before it passes. */
64
+ export async function verifyRoomFlushCredential(credential, shellSecret) {
65
+ const parts = credential.split(".");
66
+ if (parts.length !== 3 || parts[0] !== FLUSH_CREDENTIAL_PREFIX) {
67
+ throw new Error("not a room flush credential");
68
+ }
69
+ const [, body, sig] = parts;
70
+ const key = await flushHmacKey(shellSecret, "verify");
71
+ const ok = await crypto.subtle.verify("HMAC", key, unb64url(sig), new TextEncoder().encode(`${FLUSH_CREDENTIAL_PREFIX}.${body}`));
72
+ if (!ok)
73
+ throw new Error("bad signature");
74
+ let payload;
75
+ try {
76
+ payload = JSON.parse(new TextDecoder().decode(unb64url(body)));
77
+ }
78
+ catch {
79
+ throw new Error("malformed payload");
80
+ }
81
+ if (payload.v !== 1)
82
+ throw new Error("unknown version");
83
+ if (typeof payload.doc !== "string" || payload.doc.length === 0) {
84
+ throw new Error("missing doc");
85
+ }
86
+ if (typeof payload.epoch !== "number")
87
+ throw new Error("missing epoch");
88
+ return payload;
89
+ }
12
90
  export class RindleApiError extends Error {
13
91
  code;
14
92
  status;
@@ -57,6 +135,38 @@ export class SplitDaemonClient {
57
135
  rejectMutation(input) {
58
136
  return this.writes.rejectMutation(input);
59
137
  }
138
+ // Interactive mutation sessions hold the MASTER's write transaction — never a replica's
139
+ // (DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1; the follower write-fence enforces the same).
140
+ beginMutationSession(input) {
141
+ const begin = this.writes.beginMutationSession?.bind(this.writes);
142
+ if (!begin)
143
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
144
+ return begin(input);
145
+ }
146
+ execInMutationSession(input) {
147
+ const exec = this.writes.execInMutationSession?.bind(this.writes);
148
+ if (!exec)
149
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
150
+ return exec(input);
151
+ }
152
+ queryInMutationSession(input) {
153
+ const query = this.writes.queryInMutationSession?.bind(this.writes);
154
+ if (!query)
155
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
156
+ return query(input);
157
+ }
158
+ commitMutationSession(input) {
159
+ const commit = this.writes.commitMutationSession?.bind(this.writes);
160
+ if (!commit)
161
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
162
+ return commit(input);
163
+ }
164
+ rollbackMutationSession(input) {
165
+ const rollback = this.writes.rollbackMutationSession?.bind(this.writes);
166
+ if (!rollback)
167
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
168
+ return rollback(input);
169
+ }
60
170
  applyRowChangeTxn(input) {
61
171
  return this.writes.applyRowChangeTxn(input);
62
172
  }
@@ -86,18 +196,480 @@ export class SplitDaemonClient {
86
196
  return this.reads.dematerialize(input);
87
197
  }
88
198
  }
199
+ export const sqliteDialect = { name: "sqlite", placeholder: () => "?" };
200
+ export const postgresDialect = { name: "postgres", placeholder: (i) => `$${i}` };
201
+ /** Build the {@link RenderIndex} from a typed schema (`schema.tables[name]` is a `TableMeta`). */
202
+ export function buildRenderIndex(schema) {
203
+ const out = {};
204
+ const tables = schema.tables;
205
+ for (const name of Object.keys(tables)) {
206
+ const meta = tables[name];
207
+ const columns = Object.keys(meta.columns);
208
+ const types = {};
209
+ for (const c of columns)
210
+ types[c] = meta.columns[c].type;
211
+ // Same schema-derived plan the client funnel uses (design 206 §6.1), so their required-sets
212
+ // and null-fills can't drift.
213
+ const { required, nullable } = insertPlan(schema.tables[name]);
214
+ out[name] = { columns, pkNames: [...meta.primaryKey], types, required, nullable };
215
+ }
216
+ return out;
217
+ }
218
+ const quoteIdent = (name) => `"${name.replace(/"/g, '""')}"`;
219
+ function tableMeta(render, table) {
220
+ const meta = render[table];
221
+ if (!meta) {
222
+ const known = Object.keys(render);
223
+ throw new Error(`logical mutator write to unknown table ${JSON.stringify(table)} — did you pass \`schema\` to createRindleApiServer? known tables: ${known.length ? known.join(", ") : "(none — no schema configured)"}`);
224
+ }
225
+ return meta;
226
+ }
227
+ /** Validate a keyed row against a table: reject unknown columns; require the pk columns; with
228
+ * `full`, require every NON-nullable column (a nullable column may be omitted and is filled with
229
+ * `NULL`, design 206 §6.2). Mirrors the client `trackingTx.checkColumns` messages so an author sees
230
+ * the same error on both tiers. */
231
+ function checkColumns(table, obj, meta, full) {
232
+ const unknown = Object.keys(obj).filter((k) => !meta.columns.includes(k));
233
+ if (unknown.length) {
234
+ throw new Error(`unknown column${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} on ${table} — columns: ${meta.columns.join(", ")}`);
235
+ }
236
+ const required = full ? meta.required : meta.pkNames;
237
+ const missing = required.filter((c) => !(c in obj));
238
+ if (missing.length) {
239
+ throw new Error(`missing ${full ? "column" : "primary-key column"}${missing.length > 1 ? "s" : ""} ${missing.join(", ")} on ${table}`);
240
+ }
241
+ }
242
+ const encode = (dialect, v, type) => dialect.encodeValue ? dialect.encodeValue(v, type) : v;
243
+ /** Render one {@link MutationOp} to a `{sql, params}` for the dialect, or `null` for a no-op (an
244
+ * `update` whose row names only pk columns — nothing to SET, matching the client's no-op edit). */
245
+ export function renderOp(op, meta, dialect) {
246
+ const t = quoteIdent(op.table);
247
+ const params = [];
248
+ // Bind a value and return its placeholder at the correct 1-based index (post-push length).
249
+ // `insertCell` fills NULL for an omitted nullable column on the insert arm (design 206 §6.2);
250
+ // on update/delete every bound column is guaranteed present, so it is a pass-through there.
251
+ // `toCell` stringifies a `json` object (a typed mutator passes the parsed object) — a string
252
+ // passes through, so an author may still pass pre-stringified json.
253
+ const bind = (c, row) => {
254
+ params.push(encode(dialect, toCell(insertCell(row, c), meta.types[c]), meta.types[c]));
255
+ return dialect.placeholder(params.length);
256
+ };
257
+ if (op.kind === "insert" || op.kind === "insertIgnore" || op.kind === "upsert") {
258
+ checkColumns(op.table, op.row, meta, true);
259
+ const cols = meta.columns;
260
+ const values = cols.map((c) => bind(c, op.row));
261
+ let sql = `INSERT INTO ${t} (${cols.map(quoteIdent).join(", ")}) VALUES (${values.join(", ")})`;
262
+ if (op.kind === "insertIgnore") {
263
+ sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) DO NOTHING`;
264
+ }
265
+ else if (op.kind === "upsert") {
266
+ const nonPk = cols.filter((c) => !meta.pkNames.includes(c));
267
+ sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) `;
268
+ sql += nonPk.length
269
+ ? `DO UPDATE SET ${nonPk.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`).join(", ")}`
270
+ : `DO NOTHING`;
271
+ }
272
+ return { sql, params };
273
+ }
274
+ if (op.kind === "update") {
275
+ checkColumns(op.table, op.row, meta, false);
276
+ const setCols = meta.columns.filter((c) => !meta.pkNames.includes(c) && c in op.row);
277
+ if (!setCols.length)
278
+ return null; // pk-only row → nothing to change (client no-op edit)
279
+ const setSql = setCols.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
280
+ const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
281
+ return { sql: `UPDATE ${t} SET ${setSql.join(", ")} WHERE ${whereSql.join(" AND ")}`, params };
282
+ }
283
+ // delete
284
+ checkColumns(op.table, op.pk, meta, false);
285
+ const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.pk)}`);
286
+ return { sql: `DELETE FROM ${t} WHERE ${whereSql.join(" AND ")}`, params };
287
+ }
288
+ /** Render a point read (`tx.row`) — `SELECT <cols> FROM "T" WHERE <pk>` — for read-your-writes. */
289
+ export function renderPointRead(table, pk, meta, dialect) {
290
+ checkColumns(table, pk, meta, false);
291
+ const params = [];
292
+ const whereSql = meta.pkNames.map((c) => {
293
+ params.push(encode(dialect, pk[c], meta.types[c]));
294
+ return `${quoteIdent(c)} = ${dialect.placeholder(params.length)}`;
295
+ });
296
+ const cols = meta.columns.map(quoteIdent).join(", ");
297
+ return { sql: `SELECT ${cols} FROM ${quoteIdent(table)} WHERE ${whereSql.join(" AND ")}`, params };
298
+ }
299
+ /** Map a driver row (column-name keyed) to a {@link KeyedRow} over the table's known columns. */
300
+ function rowToKeyed(row, meta) {
301
+ if (!row)
302
+ return undefined;
303
+ const out = {};
304
+ for (const c of meta.columns)
305
+ out[c] = row[c];
306
+ return out;
307
+ }
308
+ // --------------------------------------------------------------------------- backends + server tx
309
+ /** Thrown (wrapping the driver error) by a server tx's DB calls, so the seam can tell an INFRA
310
+ * failure (retry) from a mutator-body throw (business rejection). */
311
+ export class BackendError extends Error {
312
+ driverError;
313
+ constructor(driverError) {
314
+ super(driverError instanceof Error ? driverError.message : String(driverError));
315
+ this.name = "BackendError";
316
+ this.driverError = driverError;
317
+ }
318
+ }
319
+ /** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
320
+ * raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
321
+ * the one interactive-txn limitation of the daemon backend). */
322
+ /** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
323
+ * schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
324
+ * the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
325
+ * map is inherently per-query. `columnTypes` are stubs: the sqlite dialect binds natives and
326
+ * never consults them (DAEMON-INTERACTIVE-TXN §5.4 — no casts). */
327
+ function catalogFor(render, root) {
328
+ const tables = {};
329
+ const ensure = (table) => {
330
+ const existing = tables[table];
331
+ if (existing)
332
+ return existing;
333
+ const meta = tableMeta(render, table);
334
+ const columnTypes = {};
335
+ for (const c of meta.columns)
336
+ columnTypes[c] = { type: "text", isEnum: false, isArray: false };
337
+ return (tables[table] = {
338
+ columns: [...meta.columns],
339
+ primaryKey: [...meta.pkNames],
340
+ columnTypes,
341
+ relationships: {},
342
+ });
343
+ };
344
+ const walkCondition = (cond) => {
345
+ if (!cond)
346
+ return;
347
+ if (cond.type === "and" || cond.type === "or") {
348
+ for (const c of cond.conditions)
349
+ walkCondition(c);
350
+ }
351
+ else if (cond.type === "correlatedSubquery") {
352
+ walkAst(cond.related.subquery);
353
+ }
354
+ };
355
+ const walkAst = (ast) => {
356
+ const t = ensure(ast.table);
357
+ for (const rel of ast.related ?? []) {
358
+ const alias = rel.subquery.alias;
359
+ if (alias != null)
360
+ t.relationships[alias] = rel.subquery.one === true ? "one" : "many";
361
+ walkAst(rel.subquery);
362
+ }
363
+ walkCondition(ast.where);
364
+ };
365
+ walkAst(root);
366
+ return { tables };
367
+ }
368
+ /** The control-flow unwind for a begin-absorbed replay (DAEMON-INTERACTIVE-TXN §4.1): thrown
369
+ * from the first read so the mutator body stops re-running an already-committed envelope; the
370
+ * backend answers with the tx's latched authoritative output. Never surfaces to users. */
371
+ class AbsorbedReplay extends Error {
372
+ constructor() {
373
+ super("mutation absorbed by mid dedup at session begin");
374
+ }
375
+ }
89
376
  /**
90
- * Today's behavior as a {@link MutationBackend}: ship the statements + envelope to the daemon,
91
- * whose `/execute-sql-txn` path stamps `lmid` co-transactionally (and `/reject-mutation` advances
92
- * it past a rejected mid). This is the default backend existing users never see a change.
377
+ * The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
378
+ * execution strategies. It starts ACCUMULATING a pure-write mutator ships one batch to
379
+ * `/execute-sql-txn`, byte-identical to prior behaviorand LAZILY UPGRADES to an interactive
380
+ * mutation session at the mutator's first read: `begin` carries the envelope identity, the
381
+ * accumulated statement prefix (sound to replay — nothing before the first read observed DB
382
+ * state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
383
+ * From then on reads run THROUGH the open transaction (read-your-writes — PG parity, and no
384
+ * read-then-write race) and writes buffer locally, flushing before the next read/commit: k
385
+ * reads cost k+2 round trips regardless of write count.
386
+ *
387
+ * Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
388
+ * the replay output is latched on {@link DaemonLazyTx.absorbed} and {@link AbsorbedReplay}
389
+ * unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
390
+ * the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
391
+ * A daemon client without session support keeps the LEGACY committed-state point read.
392
+ */
393
+ class DaemonLazyTx {
394
+ /** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
395
+ stmts = [];
396
+ render;
397
+ daemon;
398
+ envelope;
399
+ sessionId;
400
+ /** The begin-absorbed replay output (§4.1), latched for the backend. */
401
+ absorbed;
402
+ idempotencyKey;
403
+ constructor(render, daemon, envelope) {
404
+ this.render = render;
405
+ this.daemon = daemon;
406
+ this.envelope = envelope;
407
+ }
408
+ /** True once the tx upgraded to an interactive session (the backend then commits it). */
409
+ get session() {
410
+ return this.sessionId !== undefined;
411
+ }
412
+ get statements() {
413
+ return this.stmts;
414
+ }
415
+ exec(sql, params = []) {
416
+ this.stmts.push({ sql, params });
417
+ }
418
+ push(op) {
419
+ const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
420
+ if (rendered)
421
+ this.stmts.push(rendered);
422
+ return Promise.resolve();
423
+ }
424
+ insert(table, row) {
425
+ return this.push({ kind: "insert", table, row });
426
+ }
427
+ update(table, row) {
428
+ return this.push({ kind: "update", table, row });
429
+ }
430
+ upsert(table, row) {
431
+ return this.push({ kind: "upsert", table, row });
432
+ }
433
+ insertIgnore(table, row) {
434
+ return this.push({ kind: "insertIgnore", table, row });
435
+ }
436
+ delete(table, pk) {
437
+ return this.push({ kind: "delete", table, pk });
438
+ }
439
+ async row(table, pk) {
440
+ const read = renderPointRead(table, pk, tableMeta(this.render, table), sqliteDialect);
441
+ const out = await this.readThroughTxn(read);
442
+ const cells = out.rows[0];
443
+ if (!cells)
444
+ return undefined;
445
+ const keyed = {}; // the daemon returns positional cells — zip with `cols`
446
+ out.cols.forEach((c, i) => (keyed[c] = cells[i]));
447
+ return keyed;
448
+ }
449
+ /** A full-shape read inside the open transaction (§5.4): compile to ONE SQLite `SELECT`
450
+ * (native bind params, no casts — SQLite is the canonical store), ride the session like
451
+ * `row` (including the lazy upgrade / begin ride-along), parse the single JSON cell. */
452
+ async query(q) {
453
+ const ast = typeof q.ast === "function" ? q.ast() : q;
454
+ const compiled = compileQueryAst(ast, catalogFor(this.render, ast), { dialect: "sqlite" });
455
+ const out = await this.readThroughTxn({ sql: compiled.sql, params: compiled.params });
456
+ const cell = out.rows[0]?.[0];
457
+ if (typeof cell !== "string")
458
+ return ast.one === true ? null : [];
459
+ return JSON.parse(cell);
460
+ }
461
+ /** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
462
+ * back to the legacy committed-state read when the daemon client lacks sessions. */
463
+ async readThroughTxn(read) {
464
+ if (this.absorbed)
465
+ throw new AbsorbedReplay();
466
+ if (!this.daemon.beginMutationSession) {
467
+ try {
468
+ return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
469
+ }
470
+ catch (err) {
471
+ throw new BackendError(err);
472
+ }
473
+ }
474
+ try {
475
+ if (this.sessionId === undefined) {
476
+ const opened = await this.daemon.beginMutationSession({
477
+ clientID: this.envelope.clientID,
478
+ mid: this.envelope.mid,
479
+ statements: this.stmts.splice(0),
480
+ query: read,
481
+ });
482
+ if (opened.absorbed) {
483
+ const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
484
+ this.absorbed = output;
485
+ throw new AbsorbedReplay();
486
+ }
487
+ if (!opened.sessionId || !opened.read) {
488
+ throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
489
+ }
490
+ this.sessionId = opened.sessionId;
491
+ return opened.read;
492
+ }
493
+ await this.flush();
494
+ return await this.daemon.queryInMutationSession({
495
+ sessionId: this.sessionId,
496
+ sql: read.sql,
497
+ params: read.params,
498
+ });
499
+ }
500
+ catch (err) {
501
+ if (err instanceof AbsorbedReplay || err instanceof BackendError)
502
+ throw err;
503
+ throw new BackendError(err);
504
+ }
505
+ }
506
+ /** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
507
+ async flush() {
508
+ if (this.stmts.length === 0)
509
+ return;
510
+ await this.daemon.execInMutationSession({
511
+ sessionId: this.sessionId,
512
+ statements: this.stmts.splice(0),
513
+ });
514
+ }
515
+ /** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
516
+ * answers the same shape `/execute-sql-txn` does. */
517
+ async commitSession() {
518
+ try {
519
+ await this.flush();
520
+ return await this.daemon.commitMutationSession({ sessionId: this.sessionId });
521
+ }
522
+ catch (err) {
523
+ throw err instanceof BackendError ? err : new BackendError(err);
524
+ }
525
+ }
526
+ /** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
527
+ * follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
528
+ async rollbackSessionQuietly() {
529
+ if (this.sessionId === undefined)
530
+ return;
531
+ const sessionId = this.sessionId;
532
+ this.sessionId = undefined;
533
+ try {
534
+ await this.daemon.rollbackMutationSession({ sessionId });
535
+ }
536
+ catch {
537
+ // Unreachable daemon / already-expired session: the deadline rollback covers it.
538
+ }
539
+ }
540
+ }
541
+ /** The Postgres server tx: a REAL interactive transaction. Logical writes render to `$n` and run
542
+ * LIVE against the open txn; raw `exec` runs live too (after `rewrite`); `row` reads the open txn
543
+ * (read-your-writes). Ops append to an internally-serialized chain so order holds even when a legacy
544
+ * sync mutator does not `await`; the backend drains the chain (`settle`) before the lmid upsert. */
545
+ class PgLiveTx {
546
+ chain = Promise.resolve();
547
+ stmts = [];
548
+ q;
549
+ render;
550
+ rewrite;
551
+ idempotencyKey;
552
+ constructor(q, render, rewrite) {
553
+ this.q = q;
554
+ this.render = render;
555
+ this.rewrite = rewrite;
556
+ }
557
+ get statements() {
558
+ return this.stmts;
559
+ }
560
+ settle() {
561
+ return this.chain;
562
+ }
563
+ execLive(sql, params) {
564
+ this.chain = this.chain.then(async () => {
565
+ try {
566
+ await this.q.exec(sql, params);
567
+ }
568
+ catch (err) {
569
+ throw new BackendError(err);
570
+ }
571
+ });
572
+ }
573
+ exec(sql, params = []) {
574
+ const stmt = { sql: this.rewrite(sql), params };
575
+ this.stmts.push(stmt);
576
+ this.execLive(stmt.sql, params);
577
+ }
578
+ write(op) {
579
+ let rendered;
580
+ try {
581
+ rendered = renderOp(op, tableMeta(this.render, op.table), postgresDialect);
582
+ }
583
+ catch (err) {
584
+ return Promise.reject(err); // a validation error (business rejection), synchronous shape
585
+ }
586
+ if (rendered)
587
+ this.execLive(rendered.sql, rendered.params ?? []);
588
+ return this.chain;
589
+ }
590
+ insert(table, row) {
591
+ return this.write({ kind: "insert", table, row });
592
+ }
593
+ update(table, row) {
594
+ return this.write({ kind: "update", table, row });
595
+ }
596
+ upsert(table, row) {
597
+ return this.write({ kind: "upsert", table, row });
598
+ }
599
+ insertIgnore(table, row) {
600
+ return this.write({ kind: "insertIgnore", table, row });
601
+ }
602
+ delete(table, pk) {
603
+ return this.write({ kind: "delete", table, pk });
604
+ }
605
+ async row(table, pk) {
606
+ const meta = tableMeta(this.render, table);
607
+ const read = renderPointRead(table, pk, meta, postgresDialect); // validates before draining
608
+ await this.settle(); // read-your-writes: drain queued writes first
609
+ let rows;
610
+ try {
611
+ rows = await this.q.query(read.sql, read.params);
612
+ }
613
+ catch (err) {
614
+ throw new BackendError(err);
615
+ }
616
+ return rowToKeyed(rows[0], meta);
617
+ }
618
+ query() {
619
+ // The compiler's postgres dialect ships (@rindle/query-compiler); what remains is the §7
620
+ // static-catalog + driver-pin wiring (POSTGRES-READ-COMPILER-DESIGN.md Phase B).
621
+ return Promise.reject(new Error("tx.query is not wired on the Postgres backend yet (POSTGRES-READ-COMPILER-DESIGN.md Phase B) — use tx.row for point reads meanwhile"));
622
+ }
623
+ }
624
+ /**
625
+ * The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
626
+ * ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
627
+ * (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
628
+ * READ-bearing mutator lazily upgrades to an interactive mutation session
629
+ * (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
630
+ * (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
631
+ * short-circuits without re-running the body.
93
632
  */
94
633
  export function daemonBackend(daemon) {
95
634
  return {
96
- apply({ envelope, statements, idempotencyKey }) {
97
- const txn = { statements: [...statements], clientID: envelope.clientID, mid: envelope.mid };
98
- if (idempotencyKey !== undefined)
99
- txn.idempotencyKey = idempotencyKey;
100
- return daemon.executeSqlTxn(txn);
635
+ dialect: sqliteDialect,
636
+ async runMutation({ envelope, render, run }) {
637
+ const tx = new DaemonLazyTx(render, daemon, envelope);
638
+ try {
639
+ await run(tx);
640
+ }
641
+ catch (err) {
642
+ // A begin-absorbed replay: the authoritative outcome already committed — answer it,
643
+ // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
644
+ if (tx.absorbed)
645
+ return { accepted: true, output: tx.absorbed };
646
+ if (err instanceof BackendError) {
647
+ await tx.rollbackSessionQuietly();
648
+ throw err.driverError; // infra — never a user rejection
649
+ }
650
+ const reason = errMessage(err);
651
+ // Data first, watermark second: the rollback releases the single writer that the
652
+ // `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
653
+ await tx.rollbackSessionQuietly();
654
+ const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
655
+ return { accepted: false, reason, output };
656
+ }
657
+ if (tx.absorbed)
658
+ return { accepted: true, output: tx.absorbed };
659
+ if (tx.session) {
660
+ try {
661
+ return { accepted: true, output: await tx.commitSession() };
662
+ }
663
+ catch (err) {
664
+ if (err instanceof BackendError)
665
+ throw err.driverError; // infra (client retries; dedup absorbs)
666
+ throw err;
667
+ }
668
+ }
669
+ const txn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
670
+ if (tx.idempotencyKey !== undefined)
671
+ txn.idempotencyKey = tx.idempotencyKey;
672
+ return { accepted: true, output: await daemon.executeSqlTxn(txn) };
101
673
  },
102
674
  reject({ envelope, reason }) {
103
675
  return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
@@ -124,16 +696,46 @@ ON CONFLICT ("client_id") DO UPDATE
124
696
  */
125
697
  export function postgresBackend(plugger, opts = {}) {
126
698
  const rewrite = opts.rewriteSql ?? ((sql) => sql);
127
- const applyInTxn = (envelope, statements) => plugger.transaction(async (q) => {
128
- for (const s of statements)
129
- await q.exec(rewrite(s.sql), (s.params ?? []));
130
- // ALWAYS — accepted or rejected — in this same transaction (§2.2, §2.4).
699
+ // A tagged business rejection escaping `plugger.transaction` the plugger rolls the data back on
700
+ // any throw; this marker distinguishes "mutator said no" (reject) from an infra failure (rethrow).
701
+ class RejectSignal extends Error {
702
+ }
703
+ const lmidOnly = (envelope) => plugger.transaction(async (q) => {
131
704
  await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
132
705
  return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
133
706
  });
134
707
  return {
135
- apply: ({ envelope, statements }) => applyInTxn(envelope, statements),
136
- reject: ({ envelope }) => applyInTxn(envelope, []),
708
+ dialect: postgresDialect,
709
+ async runMutation({ envelope, render, run }) {
710
+ try {
711
+ const output = await plugger.transaction(async (q) => {
712
+ const tx = new PgLiveTx(q, render, rewrite);
713
+ try {
714
+ await run(tx);
715
+ await tx.settle(); // drain any un-awaited queued writes before the lmid stamp
716
+ }
717
+ catch (err) {
718
+ if (err instanceof BackendError)
719
+ throw err; // infra → rollback + propagate
720
+ throw new RejectSignal(errMessage(err)); // business → rollback data, tag for §2.4
721
+ }
722
+ // ALWAYS on the accepted path, SAME transaction (§2.2): the lmid upsert commits with data.
723
+ await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
724
+ return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
725
+ });
726
+ return { accepted: true, output };
727
+ }
728
+ catch (err) {
729
+ if (err instanceof RejectSignal) {
730
+ // Data rolled back; STILL advance lmid alone (§2.4 — the client's queue must drain).
731
+ return { accepted: false, reason: err.message, output: await lmidOnly(envelope) };
732
+ }
733
+ if (err instanceof BackendError)
734
+ throw err.driverError; // infra
735
+ throw err;
736
+ }
737
+ },
738
+ reject: ({ envelope }) => lmidOnly(envelope).then(() => undefined),
137
739
  };
138
740
  }
139
741
  /** Adapt a node-postgres `Pool` (or anything pool-shaped) to a {@link PostgresPlugger}:
@@ -256,6 +858,34 @@ export function registerQueries(queries) {
256
858
  export function defineApiMutators(mutators) {
257
859
  return mutators;
258
860
  }
861
+ /**
862
+ * Bulk-register a SHARED (generator) mutator registry as server mutators — the mutator twin of
863
+ * {@link registerQueries} (which does the same for co-located `defineQuery` values). Each shared
864
+ * mutator carries its own arg validator (`shared(schema, gen)`), so this wraps every one with the
865
+ * UNIVERSAL server triad and nothing else: parse the UNTRUSTED wire args (its `.args`), map the server
866
+ * {@link MutationContext} to the shared {@link MutatorCtx} principal, and drive the SAME body the
867
+ * client predicts ({@link runSharedMutation}). The point is that a shared mutator whose server run
868
+ * adds NO authority beyond that triad needs no hand-written wrapper.
869
+ *
870
+ * Server-only AUTHORITY the client cannot predict (a title guard, an owner-gated cascade, a
871
+ * `NOT EXISTS` dedup) stays an explicit {@link ApiMutator} that OVERRIDES the auto-wrapped default —
872
+ * spread this first, then the overrides win by key:
873
+ *
874
+ * ```ts
875
+ * mutators: defineApiMutators({
876
+ * ...sharedApiMutators(sharedMutators, (ctx) => ({ user: requireUser(ctx.user) })),
877
+ * createIssue: withTitleGuard(sharedMutators.createIssue), // + server-only policy
878
+ * deleteIssue: async (tx, raw, ctx) => { ... }, // raw owner-gated cascade
879
+ * }),
880
+ * ```
881
+ */
882
+ export function sharedApiMutators(registry, principal) {
883
+ const out = {};
884
+ for (const [name, mutator] of Object.entries(registry)) {
885
+ out[name] = (tx, raw, ctx) => runSharedMutation(mutator, mutator.args.parse(raw), principal(ctx), tx);
886
+ }
887
+ return out;
888
+ }
259
889
  export function queryResultToAst(result) {
260
890
  if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
261
891
  return result.ast();
@@ -342,6 +972,10 @@ export function createRindleApiServer(opts) {
342
972
  const mode = opts.mode ?? "normalized";
343
973
  // The mutation seam — defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
344
974
  const backend = opts.backend ?? daemonBackend(opts.daemon);
975
+ // Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
976
+ // logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
977
+ // own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
978
+ const renderIndex = opts.schema ? buildRenderIndex(opts.schema) : {};
345
979
  // Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
346
980
  // floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
347
981
  const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
@@ -413,33 +1047,37 @@ export function createRindleApiServer(opts) {
413
1047
  const pushMutation = async (input) => {
414
1048
  const context = { user: input.user, request: input.request };
415
1049
  const mutator = opts.mutators?.[input.envelope.name];
1050
+ // PRE-FLIGHT rejections — no txn, no data, `lmid` alone (the queue must still drain).
416
1051
  if (!mutator)
417
1052
  return reject(backend, input.envelope, `unknown mutator: ${input.envelope.name}`);
418
- let sql;
419
1053
  try {
420
1054
  await assertAuthorized(opts.authorizeMutation, {
421
1055
  user: input.user,
422
1056
  envelope: input.envelope,
423
1057
  context,
424
1058
  });
425
- const tx = new CollectingSqlMutationTx();
426
- const result = await mutator(tx, input.envelope.args, {
427
- user: input.user,
428
- envelope: input.envelope,
429
- daemon: opts.daemon,
430
- request: input.request,
431
- });
432
- sql = mutationResultToSqlTxn(result, tx);
433
1059
  }
434
1060
  catch (err) {
435
- return reject(backend, input.envelope, String(err?.message ?? err));
1061
+ return reject(backend, input.envelope, errMessage(err));
436
1062
  }
437
- const output = await backend.apply({
1063
+ // Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
1064
+ // rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
1065
+ const outcome = await backend.runMutation({
438
1066
  envelope: input.envelope,
439
- statements: sql.statements,
440
- idempotencyKey: sql.idempotencyKey,
1067
+ render: renderIndex,
1068
+ run: async (tx) => {
1069
+ const result = await mutator(tx, input.envelope.args, {
1070
+ user: input.user,
1071
+ envelope: input.envelope,
1072
+ daemon: opts.daemon,
1073
+ request: input.request,
1074
+ });
1075
+ applyResultToTx(result, tx);
1076
+ },
441
1077
  });
442
- return { accepted: true, rejected: false, output };
1078
+ if (outcome.accepted)
1079
+ return { accepted: true, rejected: false, output: outcome.output };
1080
+ return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
443
1081
  };
444
1082
  const pushMutations = async (input) => {
445
1083
  const out = [];
@@ -498,13 +1136,18 @@ export function createRindleApiServer(opts) {
498
1136
  throw new Error(`assertPins: ${failures.length} failed — ${failures.join("; ")}`);
499
1137
  }
500
1138
  };
501
- // The room write-authority gate (§5.3.1): endpoints are disabled until the app
502
- // opts in with `authorizeRoom` hosting an authority is never a default.
1139
+ // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
1140
+ // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
1141
+ // `authorizeRoom` (trio only). Hosting an authority is never a default.
1142
+ const realtime = opts.realtime;
1143
+ const roomAuthorizer = realtime
1144
+ ? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
1145
+ : opts.authorizeRoom;
503
1146
  const roomGate = async (context) => {
504
- if (!opts.authorizeRoom) {
1147
+ if (!roomAuthorizer) {
505
1148
  throw new RindleApiError("forbidden", "room authority not configured", 403);
506
1149
  }
507
- await assertAuthorized(opts.authorizeRoom, context);
1150
+ await assertAuthorized(roomAuthorizer, context);
508
1151
  };
509
1152
  // The store's verdict rides specific statuses + body shapes (fence / conflict /
510
1153
  // identity) the room decodes — pass a daemon HTTP error through VERBATIM.
@@ -571,6 +1214,61 @@ export function createRindleApiServer(opts) {
571
1214
  return daemonVerdict(e);
572
1215
  }
573
1216
  },
1217
+ handleRoomBootJson: async (body, context) => {
1218
+ if (!realtime) {
1219
+ throw new RindleApiError("forbidden", "realtime not configured", 403);
1220
+ }
1221
+ await assertAuthorized(realtime.authorizeBoot ?? defaultBootGate(realtime.shellSecret), context);
1222
+ const msg = parseObject(body, "room-boot request");
1223
+ const doc = parseString(msg.doc, "doc");
1224
+ if (msg.instance !== undefined)
1225
+ parseString(msg.instance, "instance"); // diagnostic identity only
1226
+ const ast = queryResultToAst(await realtime.resolveFootprint(doc, context));
1227
+ const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
1228
+ if (!claim) {
1229
+ throw new Error("the configured daemon client does not implement claimRoomEpoch");
1230
+ }
1231
+ try {
1232
+ // Claim FIRST (§2.5): the lease below is minted for THIS placement, so a boot
1233
+ // that loses the epoch race learns it here, before any materialization exists.
1234
+ const { epoch } = await claim({ doc });
1235
+ const lease = await opts.daemon.materialize({
1236
+ ast,
1237
+ // The room's upstream leg IS the normalized protocol (§3) — never the app's
1238
+ // viewer `mode`.
1239
+ mode: "normalized",
1240
+ leaseTtlMs: realtime.upstreamLeaseTtlMs ?? opts.leaseTtlMs,
1241
+ });
1242
+ const headers = realtime.mintFlushHeaders
1243
+ ? await realtime.mintFlushHeaders({ doc, epoch })
1244
+ : {
1245
+ [ROOM_FLUSH_CREDENTIAL_HEADER]: await mintRoomFlushCredential({
1246
+ shellSecret: realtime.shellSecret,
1247
+ doc,
1248
+ epoch,
1249
+ }),
1250
+ };
1251
+ const res = {
1252
+ epoch,
1253
+ upstreamLeaseToken: lease.leaseToken,
1254
+ flush: {
1255
+ urls: {
1256
+ apply: routes.applyRowChangeTxn,
1257
+ claim: routes.claimRoomEpoch,
1258
+ lmids: routes.roomLmids,
1259
+ },
1260
+ headers,
1261
+ },
1262
+ };
1263
+ const upstreamWsEndpoint = realtime.upstreamWsEndpoint ?? lease.wsEndpoint;
1264
+ if (upstreamWsEndpoint !== undefined)
1265
+ res.upstreamWsEndpoint = upstreamWsEndpoint;
1266
+ return { status: 200, body: res };
1267
+ }
1268
+ catch (e) {
1269
+ return daemonVerdict(e);
1270
+ }
1271
+ },
574
1272
  handleQueryJson: (body, context) => {
575
1273
  const msg = parseObject(body, "query request");
576
1274
  return createQueryLease({
@@ -605,22 +1303,61 @@ export function createRindleApiServer(opts) {
605
1303
  },
606
1304
  };
607
1305
  }
608
- class CollectingSqlMutationTx {
609
- stmts = [];
610
- get statements() {
611
- return this.stmts;
612
- }
613
- exec(sql, params = []) {
614
- this.stmts.push({ sql, params });
1306
+ /** Run a SHARED generator mutator (the SAME body the client predicts) against a live server
1307
+ * transaction (MUTATORS-ISOMORPHIC): bind the tier-agnostic {@link isoTx} factory and drive it —
1308
+ * each yielded logical op renders + runs against `tx` (dialect SQL, per backend), each `tx.row`
1309
+ * suspends for read-your-writes, and `tx.all` fans out. A server mutator uses this to delegate its
1310
+ * write body after parsing untrusted args and applying its server-only authority (principal, policy).
1311
+ * A mutator-body throw remains a business rejection; a DB failure propagates as infra. */
1312
+ export function runSharedMutation(mutator, args, ctx, tx) {
1313
+ return driveMutationAsync(mutator(isoTx, args, ctx), {
1314
+ apply: (op) => applyOpToServerTx(tx, op),
1315
+ read: (table, pk) => tx.row(table, pk),
1316
+ query: async (q) => {
1317
+ // The shared-seam contract is ALWAYS an array of rows: the client's `WriteTxn.query` returns
1318
+ // an array even for a root `.one()` (the root unwrap is the Store's `materialize()`, not the
1319
+ // in-mutator read — rust/src/wasm/db.rs). The server's `tx.query` returns a scalar (object|null)
1320
+ // for a `.one()` root, so normalize it to `[]`/`[row]` here — one body sees the same shape on
1321
+ // both tiers. (Postgres backend: `tx.query` rejects until POSTGRES-READ-COMPILER-DESIGN.md
1322
+ // Phase B; the daemon/SQLite backend compiles + rides the open session — DAEMON §5.4.)
1323
+ const ast = q.ast();
1324
+ const res = await tx.query(ast);
1325
+ if (ast.one === true)
1326
+ return res == null ? [] : [res];
1327
+ return (res ?? []);
1328
+ },
1329
+ });
1330
+ }
1331
+ /** Run one logical {@link MutationOp} (yielded by a shared generator mutator) against the live server
1332
+ * write surface — the SAME async methods a plain async mutator calls (they render dialect SQL and
1333
+ * execute/accumulate per backend). */
1334
+ function applyOpToServerTx(tx, op) {
1335
+ switch (op.kind) {
1336
+ case "insert":
1337
+ return tx.insert(op.table, op.row);
1338
+ case "upsert":
1339
+ return tx.upsert(op.table, op.row);
1340
+ case "insertIgnore":
1341
+ return tx.insertIgnore(op.table, op.row);
1342
+ case "update":
1343
+ return tx.update(op.table, op.row);
1344
+ case "delete":
1345
+ return tx.delete(op.table, op.pk);
615
1346
  }
616
1347
  }
617
- function mutationResultToSqlTxn(result, tx) {
618
- if (Array.isArray(result))
619
- return { statements: result };
620
- if (result && typeof result === "object" && "statements" in result) {
621
- return { statements: result.statements, idempotencyKey: result.idempotencyKey };
1348
+ /** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
1349
+ * the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
1350
+ * `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
1351
+ * no-op the mutator already drove the tx. Preserves the pre-existing return-style contract. */
1352
+ function applyResultToTx(result, tx) {
1353
+ if (!result)
1354
+ return;
1355
+ const statements = Array.isArray(result) ? result : result.statements;
1356
+ for (const s of statements)
1357
+ tx.exec(s.sql, s.params);
1358
+ if (!Array.isArray(result) && result.idempotencyKey !== undefined) {
1359
+ tx.idempotencyKey = result.idempotencyKey;
622
1360
  }
623
- return { statements: [...tx.statements] };
624
1361
  }
625
1362
  async function assertAuthorized(authorizer, input) {
626
1363
  if (!authorizer)
@@ -629,6 +1366,65 @@ async function assertAuthorized(authorizer, input) {
629
1366
  if (result === false)
630
1367
  throw new RindleApiError("forbidden", "rindle request forbidden", 403);
631
1368
  }
1369
+ /** The default flush-trio gate: verify the default epoch-bound credential from the request
1370
+ * header. The two refusal messages are deliberately distinct — "missing" is a transport wiring
1371
+ * bug (no `context.request`), "refused" is an invalid credential. */
1372
+ function defaultFlushGate(shellSecret) {
1373
+ return async (context) => {
1374
+ const credential = requestHeader(context.request, ROOM_FLUSH_CREDENTIAL_HEADER);
1375
+ if (!credential) {
1376
+ throw new RindleApiError("forbidden", `missing ${ROOM_FLUSH_CREDENTIAL_HEADER} header — is the transport passing its incoming request as context.request?`, 403);
1377
+ }
1378
+ try {
1379
+ await verifyRoomFlushCredential(credential, shellSecret);
1380
+ }
1381
+ catch (e) {
1382
+ throw new RindleApiError("forbidden", `flush credential refused: ${errMessage(e)}`, 403);
1383
+ }
1384
+ };
1385
+ }
1386
+ /** The default `/room-boot` gate: `Authorization: Bearer <shell secret>` (README contract),
1387
+ * compared constant-time. */
1388
+ function defaultBootGate(shellSecret) {
1389
+ return (context) => {
1390
+ const auth = requestHeader(context.request, "authorization");
1391
+ const bearer = auth && /^bearer\s/i.test(auth) ? auth.replace(/^bearer\s+/i, "") : undefined;
1392
+ if (!bearer || !timingSafeEqualStr(bearer, shellSecret)) {
1393
+ throw new RindleApiError("forbidden", "room-boot: shell secret refused", 403);
1394
+ }
1395
+ };
1396
+ }
1397
+ /** Best-effort header extraction from whatever the transport put in `context.request`: a Fetch
1398
+ * `Request` (`headers.get`), a node `IncomingMessage` (lowercased header map), or a plain
1399
+ * `{headers: {...}}`. `undefined` when there is no request or no such header. */
1400
+ function requestHeader(request, name) {
1401
+ if (!request || typeof request !== "object")
1402
+ return undefined;
1403
+ const headers = request.headers;
1404
+ if (!headers || typeof headers !== "object")
1405
+ return undefined;
1406
+ if (typeof headers.get === "function") {
1407
+ return headers.get(name) ?? undefined;
1408
+ }
1409
+ const map = headers;
1410
+ const v = map[name.toLowerCase()] ?? map[name];
1411
+ if (typeof v === "string")
1412
+ return v;
1413
+ if (Array.isArray(v) && typeof v[0] === "string")
1414
+ return v[0];
1415
+ return undefined;
1416
+ }
1417
+ /** Constant-time string equality (a length mismatch fails fast — length is not the secret). */
1418
+ function timingSafeEqualStr(a, b) {
1419
+ const ab = new TextEncoder().encode(a);
1420
+ const bb = new TextEncoder().encode(b);
1421
+ if (ab.length !== bb.length)
1422
+ return false;
1423
+ let diff = 0;
1424
+ for (let i = 0; i < ab.length; i++)
1425
+ diff |= ab[i] ^ bb[i];
1426
+ return diff === 0;
1427
+ }
632
1428
  async function resolvePolicy(policy, input) {
633
1429
  if (!policy)
634
1430
  return { kind: "whileSubscribed" };