@rindle/api-server 0.4.2 → 0.4.4
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 +31 -60
- package/dist/index.d.ts +90 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +225 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +328 -13
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { driveMutationAsync, isoTx } from "@rindle/client";
|
|
1
|
+
import { driveMutationAsync, insertCell, insertPlan, isoTx, toCell } from "@rindle/client";
|
|
2
2
|
import type {
|
|
3
3
|
Ast,
|
|
4
4
|
ColType,
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
MutatorCtx,
|
|
10
10
|
NamedQuery,
|
|
11
11
|
Query,
|
|
12
|
+
QueryResultRow,
|
|
12
13
|
Schema,
|
|
13
14
|
SharedMutator,
|
|
14
15
|
SharedMutatorWithArgs,
|
|
@@ -65,6 +66,8 @@ export const DEFAULT_RINDLE_API_ROUTES = {
|
|
|
65
66
|
applyRowChangeTxn: "/api/rindle/apply-row-change-txn",
|
|
66
67
|
claimRoomEpoch: "/api/rindle/claim-room-epoch",
|
|
67
68
|
roomLmids: "/api/rindle/room-lmids",
|
|
69
|
+
// The DO shell's cold-boot callback (§10.1) — active only when `realtime` is configured.
|
|
70
|
+
roomBoot: "/api/rindle/room-boot",
|
|
68
71
|
} as const;
|
|
69
72
|
|
|
70
73
|
export type MaybePromise<T> = T | PromiseLike<T>;
|
|
@@ -257,6 +260,7 @@ export interface RindleApiRoutes {
|
|
|
257
260
|
applyRowChangeTxn: string;
|
|
258
261
|
claimRoomEpoch: string;
|
|
259
262
|
roomLmids: string;
|
|
263
|
+
roomBoot: string;
|
|
260
264
|
}
|
|
261
265
|
|
|
262
266
|
/** A room-host reply the transport writes VERBATIM (`status` + JSON `body`): the
|
|
@@ -282,6 +286,165 @@ export interface PinFanout {
|
|
|
282
286
|
assertPins(pins: readonly MaterializeInput[]): Promise<void>;
|
|
283
287
|
}
|
|
284
288
|
|
|
289
|
+
// --------------------------------------------------------------------------- realtime (room) host
|
|
290
|
+
//
|
|
291
|
+
// Enabling Rindle Realtime for an app (RINDLE-REALTIME-ENABLEMENT-DESIGN.md §3.1) is ONE named
|
|
292
|
+
// opt-in: the `realtime` options block. Its presence activates the flush trio AND `/room-boot`;
|
|
293
|
+
// its absence keeps every room endpoint 403 and adds no route. The deprecated bare `authorizeRoom`
|
|
294
|
+
// still gates the trio alone (it never activates `/room-boot`).
|
|
295
|
+
|
|
296
|
+
/** The room-boot flush leg (enablement §5): where the placed room's write-behind lands and what
|
|
297
|
+
* credential it presents. `urls` are the trio's ROUTE PATHS (root-relative — the shell resolves
|
|
298
|
+
* them against the boot call's origin), so an app that overrides `routes` needs no out-of-band
|
|
299
|
+
* sync; `headers` ride every flush call verbatim (`httpAuthority`'s `headers`). */
|
|
300
|
+
export interface RoomBootFlush {
|
|
301
|
+
urls: { apply: string; claim: string; lmids: string };
|
|
302
|
+
headers: Record<string, string>;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The `/room-boot` response (RINDLE-REALTIME-DESIGN.md §10.1 — the DO shell's cold-boot
|
|
306
|
+
* callback): the claimed placement epoch, the room's upstream footprint lease, and the flush
|
|
307
|
+
* leg. A cold room boots inert and serves nothing until this returns. */
|
|
308
|
+
export interface RoomBootResponse {
|
|
309
|
+
epoch: number;
|
|
310
|
+
upstreamLeaseToken: string;
|
|
311
|
+
/** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
|
|
312
|
+
* shell's statically configured rindled ws endpoint. */
|
|
313
|
+
upstreamWsEndpoint?: string;
|
|
314
|
+
flush: RoomBootFlush;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface RindleRealtimeOptions<User> {
|
|
318
|
+
/** The boot shell secret (§10.1 — a host binding, never client-derived). Authenticates the
|
|
319
|
+
* room's `Authorization: Bearer` on `/room-boot` (the default {@link authorizeBoot}) and keys
|
|
320
|
+
* the DEFAULT epoch-bound flush credential. */
|
|
321
|
+
shellSecret: string;
|
|
322
|
+
/** doc → the room's approved upstream footprint (§3.1) — an `Ast` or fluent `Query`. MAY
|
|
323
|
+
* delegate to the named-query registry internally; throw `RindleApiError("not-found", …, 404)`
|
|
324
|
+
* for a doc that shouldn't exist. The §9 footprint budget belongs here — it runs once per
|
|
325
|
+
* placement, at lease mint. */
|
|
326
|
+
resolveFootprint: (doc: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;
|
|
327
|
+
/** Gates the flush trio — `authorizeRoom`, relocated. Default: verify the default flush
|
|
328
|
+
* credential from the {@link ROOM_FLUSH_CREDENTIAL_HEADER} request header — which requires the
|
|
329
|
+
* transport to pass its incoming request as `context.request` (Fetch `Request` and node
|
|
330
|
+
* `IncomingMessage` shapes are both understood). */
|
|
331
|
+
authorize?: Authorizer<ApiContext<User>>;
|
|
332
|
+
/** Gates `/room-boot`. Default: constant-time `Authorization: Bearer` check against
|
|
333
|
+
* {@link shellSecret} (same `context.request` requirement as {@link authorize}). */
|
|
334
|
+
authorizeBoot?: Authorizer<ApiContext<User>>;
|
|
335
|
+
/** Mint the flush headers a placed room presents on every flush call. Default:
|
|
336
|
+
* {@link mintRoomFlushCredential} under {@link ROOM_FLUSH_CREDENTIAL_HEADER}. Override this
|
|
337
|
+
* and {@link authorize} TOGETHER — they are the two ends of one credential. */
|
|
338
|
+
mintFlushHeaders?: (input: { doc: string; epoch: number }) => MaybePromise<Record<string, string>>;
|
|
339
|
+
/** Lease TTL for the room's upstream footprint materialization (defaults to the server-wide
|
|
340
|
+
* `leaseTtlMs`, else the daemon's default). */
|
|
341
|
+
upstreamLeaseTtlMs?: number;
|
|
342
|
+
/** Static override for where rooms open their upstream subscription; default is the
|
|
343
|
+
* `wsEndpoint` the materialize returns (set in routed deploys), else absent. */
|
|
344
|
+
upstreamWsEndpoint?: string;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The DEFAULT flush credential: `rfc1.<b64url payload>.<b64url hmac-sha256>`, payload
|
|
348
|
+
// `{v:1, doc, epoch, iat}`. Deliberately EPOCH-bound, not time-bound: the credential's lifecycle
|
|
349
|
+
// IS the placement fence (§8.3 — a superseded epoch's flushes 409 at the store no matter what
|
|
350
|
+
// credential they carry), and platform revocation is registry suspension, so an `exp` would only
|
|
351
|
+
// force spurious re-boots of long-lived rooms. HMAC via WebCrypto (`crypto.subtle`) so the exact
|
|
352
|
+
// same code runs in Node and a Cloudflare Worker — no `node:crypto` import (matching
|
|
353
|
+
// `@rindle/room`'s token module).
|
|
354
|
+
|
|
355
|
+
export const ROOM_FLUSH_CREDENTIAL_HEADER = "x-rindle-room-credential";
|
|
356
|
+
|
|
357
|
+
const FLUSH_CREDENTIAL_PREFIX = "rfc1";
|
|
358
|
+
|
|
359
|
+
export interface RoomFlushCredentialPayload {
|
|
360
|
+
v: 1;
|
|
361
|
+
doc: string;
|
|
362
|
+
epoch: number;
|
|
363
|
+
iat: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function b64url(bytes: Uint8Array): string {
|
|
367
|
+
let bin = "";
|
|
368
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
369
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Return type inferred (`Uint8Array<ArrayBuffer>` under TS ≥5.7 libs) — an explicit
|
|
373
|
+
// `Uint8Array` annotation widens to `ArrayBufferLike` and fails `crypto.subtle`'s
|
|
374
|
+
// `BufferSource` under consumers compiling this source with newer lib types.
|
|
375
|
+
function unb64url(s: string) {
|
|
376
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
|
|
377
|
+
const out = new Uint8Array(bin.length);
|
|
378
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function flushHmacKey(secret: string, usage: "sign" | "verify") {
|
|
383
|
+
return crypto.subtle.importKey(
|
|
384
|
+
"raw",
|
|
385
|
+
new TextEncoder().encode(secret),
|
|
386
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
387
|
+
false,
|
|
388
|
+
[usage],
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Sign the default epoch-bound flush credential (`/room-boot` mints one per placement). */
|
|
393
|
+
export async function mintRoomFlushCredential(opts: {
|
|
394
|
+
shellSecret: string;
|
|
395
|
+
doc: string;
|
|
396
|
+
epoch: number;
|
|
397
|
+
/** Mint time; defaults to `Date.now()`. Injectable for tests. */
|
|
398
|
+
now?: number;
|
|
399
|
+
}): Promise<string> {
|
|
400
|
+
const payload: RoomFlushCredentialPayload = {
|
|
401
|
+
v: 1,
|
|
402
|
+
doc: opts.doc,
|
|
403
|
+
epoch: opts.epoch,
|
|
404
|
+
iat: opts.now ?? Date.now(),
|
|
405
|
+
};
|
|
406
|
+
const body = b64url(new TextEncoder().encode(JSON.stringify(payload)));
|
|
407
|
+
const signed = `${FLUSH_CREDENTIAL_PREFIX}.${body}`;
|
|
408
|
+
const key = await flushHmacKey(opts.shellSecret, "sign");
|
|
409
|
+
const sig = new Uint8Array(
|
|
410
|
+
await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed)),
|
|
411
|
+
);
|
|
412
|
+
return `${signed}.${b64url(sig)}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Verify a flush credential's MAC, then its claims; returns the payload or throws. The MAC is
|
|
416
|
+
* checked FIRST — no claim is trusted before it passes. */
|
|
417
|
+
export async function verifyRoomFlushCredential(
|
|
418
|
+
credential: string,
|
|
419
|
+
shellSecret: string,
|
|
420
|
+
): Promise<RoomFlushCredentialPayload> {
|
|
421
|
+
const parts = credential.split(".");
|
|
422
|
+
if (parts.length !== 3 || parts[0] !== FLUSH_CREDENTIAL_PREFIX) {
|
|
423
|
+
throw new Error("not a room flush credential");
|
|
424
|
+
}
|
|
425
|
+
const [, body, sig] = parts;
|
|
426
|
+
const key = await flushHmacKey(shellSecret, "verify");
|
|
427
|
+
const ok = await crypto.subtle.verify(
|
|
428
|
+
"HMAC",
|
|
429
|
+
key,
|
|
430
|
+
unb64url(sig),
|
|
431
|
+
new TextEncoder().encode(`${FLUSH_CREDENTIAL_PREFIX}.${body}`),
|
|
432
|
+
);
|
|
433
|
+
if (!ok) throw new Error("bad signature");
|
|
434
|
+
let payload: RoomFlushCredentialPayload;
|
|
435
|
+
try {
|
|
436
|
+
payload = JSON.parse(new TextDecoder().decode(unb64url(body))) as RoomFlushCredentialPayload;
|
|
437
|
+
} catch {
|
|
438
|
+
throw new Error("malformed payload");
|
|
439
|
+
}
|
|
440
|
+
if (payload.v !== 1) throw new Error("unknown version");
|
|
441
|
+
if (typeof payload.doc !== "string" || payload.doc.length === 0) {
|
|
442
|
+
throw new Error("missing doc");
|
|
443
|
+
}
|
|
444
|
+
if (typeof payload.epoch !== "number") throw new Error("missing epoch");
|
|
445
|
+
return payload;
|
|
446
|
+
}
|
|
447
|
+
|
|
285
448
|
export interface RindleApiServerOptions<User> {
|
|
286
449
|
daemon: RindleDaemonClient;
|
|
287
450
|
/** Where mutations are applied and `lmid` is stamped ({@link MutationBackend}). Default:
|
|
@@ -298,10 +461,16 @@ export interface RindleApiServerOptions<User> {
|
|
|
298
461
|
mutators?: ApiMutators<User>;
|
|
299
462
|
authorizeQuery?: Authorizer<AuthorizeQueryInput<User>>;
|
|
300
463
|
authorizeMutation?: Authorizer<AuthorizeMutationInput<User>>;
|
|
464
|
+
/** Rindle Realtime (RINDLE-REALTIME-ENABLEMENT-DESIGN.md §3.1): the ONE named opt-in.
|
|
465
|
+
* Presence activates the room flush trio AND `/room-boot`; absence keeps every room
|
|
466
|
+
* endpoint 403. Takes precedence over the deprecated {@link authorizeRoom}. */
|
|
467
|
+
realtime?: RindleRealtimeOptions<User>;
|
|
301
468
|
/** The room write-authority gate (§5.3.1): validates the caller is a placed room —
|
|
302
469
|
* the epoch-bound flush credential rides `context.request`, and what it means is
|
|
303
470
|
* the app's to define. The room endpoints are DISABLED (403) until this is set:
|
|
304
|
-
* hosting a write authority is an explicit opt-in, never a default.
|
|
471
|
+
* hosting a write authority is an explicit opt-in, never a default.
|
|
472
|
+
* @deprecated Use {@link realtime} (its `authorize`) — this bare form gates the
|
|
473
|
+
* flush trio but never activates `/room-boot`. When both are set, `realtime` wins. */
|
|
305
474
|
authorizeRoom?: Authorizer<ApiContext<User>>;
|
|
306
475
|
routes?: Partial<RindleApiRoutes>;
|
|
307
476
|
mode?: StreamMode;
|
|
@@ -383,6 +552,10 @@ export interface RindleApiServer<User> {
|
|
|
383
552
|
handleClaimRoomEpochJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
|
|
384
553
|
/** The room's boot probe (§3.3), same gate + envelope. */
|
|
385
554
|
handleRoomLmidsJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
|
|
555
|
+
/** The DO shell's cold-boot callback (§10.1; enablement §3.1): authenticate the shell
|
|
556
|
+
* secret, resolve the doc's footprint, claim the placement epoch, mint the upstream
|
|
557
|
+
* lease and the flush leg. 403 until {@link RindleApiServerOptions.realtime} is set. */
|
|
558
|
+
handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
|
|
386
559
|
}
|
|
387
560
|
|
|
388
561
|
export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
|
|
@@ -522,6 +695,10 @@ export interface TableRenderMeta {
|
|
|
522
695
|
pkNames: string[];
|
|
523
696
|
/** Column name → declared type (only consulted by {@link SqlDialect.encodeValue}). */
|
|
524
697
|
types: Record<string, ColType>;
|
|
698
|
+
/** Columns a full insert must name — the non-nullable ones (design 206 §6.2). */
|
|
699
|
+
required: string[];
|
|
700
|
+
/** The nullable (omittable-to-null) columns — an omitted one binds `NULL` (design 206 §6.2). */
|
|
701
|
+
nullable: ReadonlySet<string>;
|
|
525
702
|
}
|
|
526
703
|
|
|
527
704
|
export type RenderIndex = Record<string, TableRenderMeta>;
|
|
@@ -535,7 +712,10 @@ export function buildRenderIndex(schema: Schema): RenderIndex {
|
|
|
535
712
|
const columns = Object.keys(meta.columns);
|
|
536
713
|
const types: Record<string, ColType> = {};
|
|
537
714
|
for (const c of columns) types[c] = meta.columns[c].type;
|
|
538
|
-
|
|
715
|
+
// Same schema-derived plan the client funnel uses (design 206 §6.1), so their required-sets
|
|
716
|
+
// and null-fills can't drift.
|
|
717
|
+
const { required, nullable } = insertPlan(schema.tables[name]);
|
|
718
|
+
out[name] = { columns, pkNames: [...meta.primaryKey], types, required, nullable };
|
|
539
719
|
}
|
|
540
720
|
return out;
|
|
541
721
|
}
|
|
@@ -554,14 +734,15 @@ function tableMeta(render: RenderIndex, table: string): TableRenderMeta {
|
|
|
554
734
|
}
|
|
555
735
|
|
|
556
736
|
/** Validate a keyed row against a table: reject unknown columns; require the pk columns; with
|
|
557
|
-
* `full`, require
|
|
558
|
-
* client `trackingTx.checkColumns` messages so an author sees
|
|
737
|
+
* `full`, require every NON-nullable column (a nullable column may be omitted and is filled with
|
|
738
|
+
* `NULL`, design 206 §6.2). Mirrors the client `trackingTx.checkColumns` messages so an author sees
|
|
739
|
+
* the same error on both tiers. */
|
|
559
740
|
function checkColumns(table: string, obj: KeyedRow, meta: TableRenderMeta, full: boolean): void {
|
|
560
741
|
const unknown = Object.keys(obj).filter((k) => !meta.columns.includes(k));
|
|
561
742
|
if (unknown.length) {
|
|
562
743
|
throw new Error(`unknown column${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} on ${table} — columns: ${meta.columns.join(", ")}`);
|
|
563
744
|
}
|
|
564
|
-
const required = full ? meta.
|
|
745
|
+
const required = full ? meta.required : meta.pkNames;
|
|
565
746
|
const missing = required.filter((c) => !(c in obj));
|
|
566
747
|
if (missing.length) {
|
|
567
748
|
throw new Error(`missing ${full ? "column" : "primary-key column"}${missing.length > 1 ? "s" : ""} ${missing.join(", ")} on ${table}`);
|
|
@@ -577,8 +758,12 @@ export function renderOp(op: MutationOp, meta: TableRenderMeta, dialect: SqlDial
|
|
|
577
758
|
const t = quoteIdent(op.table);
|
|
578
759
|
const params: WireValue[] = [];
|
|
579
760
|
// Bind a value and return its placeholder at the correct 1-based index (post-push length).
|
|
761
|
+
// `insertCell` fills NULL for an omitted nullable column on the insert arm (design 206 §6.2);
|
|
762
|
+
// on update/delete every bound column is guaranteed present, so it is a pass-through there.
|
|
763
|
+
// `toCell` stringifies a `json` object (a typed mutator passes the parsed object) — a string
|
|
764
|
+
// passes through, so an author may still pass pre-stringified json.
|
|
580
765
|
const bind = (c: string, row: KeyedRow): string => {
|
|
581
|
-
params.push(encode(dialect, row[c], meta.types[c]));
|
|
766
|
+
params.push(encode(dialect, toCell(insertCell(row, c), meta.types[c]), meta.types[c]));
|
|
582
767
|
return dialect.placeholder(params.length);
|
|
583
768
|
};
|
|
584
769
|
|
|
@@ -1287,8 +1472,9 @@ export async function dumpQueryShapes<User>(opts: {
|
|
|
1287
1472
|
exemplars?: Partial<Record<string, ReadonlyArray<ShapeExemplar<User>>>>;
|
|
1288
1473
|
}): Promise<QueryShapesDoc> {
|
|
1289
1474
|
const tables = Object.values(opts.schema.tables)
|
|
1290
|
-
// Local-only tables live in the browser's memory source,
|
|
1291
|
-
|
|
1475
|
+
// Local-only tables (both `true` and `"session"`) live in the browser's memory source,
|
|
1476
|
+
// never a TableSource — no indexes.
|
|
1477
|
+
.filter((t) => !t.local)
|
|
1292
1478
|
.map((t) => ({ name: t.name, primaryKey: [...t.primaryKey] }))
|
|
1293
1479
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1294
1480
|
const queries: QueryShapesDoc["queries"] = [];
|
|
@@ -1517,13 +1703,18 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1517
1703
|
}
|
|
1518
1704
|
};
|
|
1519
1705
|
|
|
1520
|
-
// The room write-authority gate (§5.3.1): endpoints are disabled until the app
|
|
1521
|
-
//
|
|
1706
|
+
// The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
|
|
1707
|
+
// the `realtime` block (which also activates `/room-boot`) or the deprecated bare
|
|
1708
|
+
// `authorizeRoom` (trio only). Hosting an authority is never a default.
|
|
1709
|
+
const realtime = opts.realtime;
|
|
1710
|
+
const roomAuthorizer: Authorizer<ApiContext<User>> | undefined = realtime
|
|
1711
|
+
? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
|
|
1712
|
+
: opts.authorizeRoom;
|
|
1522
1713
|
const roomGate = async (context: ApiContext<User>): Promise<void> => {
|
|
1523
|
-
if (!
|
|
1714
|
+
if (!roomAuthorizer) {
|
|
1524
1715
|
throw new RindleApiError("forbidden", "room authority not configured", 403);
|
|
1525
1716
|
}
|
|
1526
|
-
await assertAuthorized(
|
|
1717
|
+
await assertAuthorized(roomAuthorizer, context);
|
|
1527
1718
|
};
|
|
1528
1719
|
|
|
1529
1720
|
// The store's verdict rides specific statuses + body shapes (fence / conflict /
|
|
@@ -1588,6 +1779,58 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1588
1779
|
return daemonVerdict(e);
|
|
1589
1780
|
}
|
|
1590
1781
|
},
|
|
1782
|
+
handleRoomBootJson: async (body, context) => {
|
|
1783
|
+
if (!realtime) {
|
|
1784
|
+
throw new RindleApiError("forbidden", "realtime not configured", 403);
|
|
1785
|
+
}
|
|
1786
|
+
await assertAuthorized(realtime.authorizeBoot ?? defaultBootGate(realtime.shellSecret), context);
|
|
1787
|
+
const msg = parseObject(body, "room-boot request");
|
|
1788
|
+
const doc = parseString(msg.doc, "doc");
|
|
1789
|
+
if (msg.instance !== undefined) parseString(msg.instance, "instance"); // diagnostic identity only
|
|
1790
|
+
const ast = queryResultToAst(await realtime.resolveFootprint(doc, context));
|
|
1791
|
+
const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
|
|
1792
|
+
if (!claim) {
|
|
1793
|
+
throw new Error("the configured daemon client does not implement claimRoomEpoch");
|
|
1794
|
+
}
|
|
1795
|
+
try {
|
|
1796
|
+
// Claim FIRST (§2.5): the lease below is minted for THIS placement, so a boot
|
|
1797
|
+
// that loses the epoch race learns it here, before any materialization exists.
|
|
1798
|
+
const { epoch } = await claim({ doc });
|
|
1799
|
+
const lease = await opts.daemon.materialize({
|
|
1800
|
+
ast,
|
|
1801
|
+
// The room's upstream leg IS the normalized protocol (§3) — never the app's
|
|
1802
|
+
// viewer `mode`.
|
|
1803
|
+
mode: "normalized",
|
|
1804
|
+
leaseTtlMs: realtime.upstreamLeaseTtlMs ?? opts.leaseTtlMs,
|
|
1805
|
+
});
|
|
1806
|
+
const headers = realtime.mintFlushHeaders
|
|
1807
|
+
? await realtime.mintFlushHeaders({ doc, epoch })
|
|
1808
|
+
: {
|
|
1809
|
+
[ROOM_FLUSH_CREDENTIAL_HEADER]: await mintRoomFlushCredential({
|
|
1810
|
+
shellSecret: realtime.shellSecret,
|
|
1811
|
+
doc,
|
|
1812
|
+
epoch,
|
|
1813
|
+
}),
|
|
1814
|
+
};
|
|
1815
|
+
const res: RoomBootResponse = {
|
|
1816
|
+
epoch,
|
|
1817
|
+
upstreamLeaseToken: lease.leaseToken,
|
|
1818
|
+
flush: {
|
|
1819
|
+
urls: {
|
|
1820
|
+
apply: routes.applyRowChangeTxn,
|
|
1821
|
+
claim: routes.claimRoomEpoch,
|
|
1822
|
+
lmids: routes.roomLmids,
|
|
1823
|
+
},
|
|
1824
|
+
headers,
|
|
1825
|
+
},
|
|
1826
|
+
};
|
|
1827
|
+
const upstreamWsEndpoint = realtime.upstreamWsEndpoint ?? lease.wsEndpoint;
|
|
1828
|
+
if (upstreamWsEndpoint !== undefined) res.upstreamWsEndpoint = upstreamWsEndpoint;
|
|
1829
|
+
return { status: 200, body: res };
|
|
1830
|
+
} catch (e) {
|
|
1831
|
+
return daemonVerdict(e);
|
|
1832
|
+
}
|
|
1833
|
+
},
|
|
1591
1834
|
handleQueryJson: (body, context) => {
|
|
1592
1835
|
const msg = parseObject(body, "query request");
|
|
1593
1836
|
return createQueryLease({
|
|
@@ -1638,6 +1881,18 @@ export function runSharedMutation<Args, Ctx extends MutatorCtx>(
|
|
|
1638
1881
|
return driveMutationAsync(mutator(isoTx, args, ctx), {
|
|
1639
1882
|
apply: (op) => applyOpToServerTx(tx, op),
|
|
1640
1883
|
read: (table, pk) => tx.row(table, pk),
|
|
1884
|
+
query: async (q) => {
|
|
1885
|
+
// The shared-seam contract is ALWAYS an array of rows: the client's `WriteTxn.query` returns
|
|
1886
|
+
// an array even for a root `.one()` (the root unwrap is the Store's `materialize()`, not the
|
|
1887
|
+
// in-mutator read — rust/src/wasm/db.rs). The server's `tx.query` returns a scalar (object|null)
|
|
1888
|
+
// for a `.one()` root, so normalize it to `[]`/`[row]` here — one body sees the same shape on
|
|
1889
|
+
// both tiers. (Postgres backend: `tx.query` rejects until POSTGRES-READ-COMPILER-DESIGN.md
|
|
1890
|
+
// Phase B; the daemon/SQLite backend compiles + rides the open session — DAEMON §5.4.)
|
|
1891
|
+
const ast = q.ast();
|
|
1892
|
+
const res = await tx.query(ast);
|
|
1893
|
+
if (ast.one === true) return res == null ? [] : [res as QueryResultRow];
|
|
1894
|
+
return (res ?? []) as QueryResultRow[];
|
|
1895
|
+
},
|
|
1641
1896
|
});
|
|
1642
1897
|
}
|
|
1643
1898
|
|
|
@@ -1678,6 +1933,66 @@ async function assertAuthorized<T>(authorizer: Authorizer<T> | undefined, input:
|
|
|
1678
1933
|
if (result === false) throw new RindleApiError("forbidden", "rindle request forbidden", 403);
|
|
1679
1934
|
}
|
|
1680
1935
|
|
|
1936
|
+
/** The default flush-trio gate: verify the default epoch-bound credential from the request
|
|
1937
|
+
* header. The two refusal messages are deliberately distinct — "missing" is a transport wiring
|
|
1938
|
+
* bug (no `context.request`), "refused" is an invalid credential. */
|
|
1939
|
+
function defaultFlushGate<User>(shellSecret: string): Authorizer<ApiContext<User>> {
|
|
1940
|
+
return async (context) => {
|
|
1941
|
+
const credential = requestHeader(context.request, ROOM_FLUSH_CREDENTIAL_HEADER);
|
|
1942
|
+
if (!credential) {
|
|
1943
|
+
throw new RindleApiError(
|
|
1944
|
+
"forbidden",
|
|
1945
|
+
`missing ${ROOM_FLUSH_CREDENTIAL_HEADER} header — is the transport passing its incoming request as context.request?`,
|
|
1946
|
+
403,
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
try {
|
|
1950
|
+
await verifyRoomFlushCredential(credential, shellSecret);
|
|
1951
|
+
} catch (e) {
|
|
1952
|
+
throw new RindleApiError("forbidden", `flush credential refused: ${errMessage(e)}`, 403);
|
|
1953
|
+
}
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/** The default `/room-boot` gate: `Authorization: Bearer <shell secret>` (README contract),
|
|
1958
|
+
* compared constant-time. */
|
|
1959
|
+
function defaultBootGate<User>(shellSecret: string): Authorizer<ApiContext<User>> {
|
|
1960
|
+
return (context) => {
|
|
1961
|
+
const auth = requestHeader(context.request, "authorization");
|
|
1962
|
+
const bearer = auth && /^bearer\s/i.test(auth) ? auth.replace(/^bearer\s+/i, "") : undefined;
|
|
1963
|
+
if (!bearer || !timingSafeEqualStr(bearer, shellSecret)) {
|
|
1964
|
+
throw new RindleApiError("forbidden", "room-boot: shell secret refused", 403);
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/** Best-effort header extraction from whatever the transport put in `context.request`: a Fetch
|
|
1970
|
+
* `Request` (`headers.get`), a node `IncomingMessage` (lowercased header map), or a plain
|
|
1971
|
+
* `{headers: {...}}`. `undefined` when there is no request or no such header. */
|
|
1972
|
+
function requestHeader(request: unknown, name: string): string | undefined {
|
|
1973
|
+
if (!request || typeof request !== "object") return undefined;
|
|
1974
|
+
const headers = (request as { headers?: unknown }).headers;
|
|
1975
|
+
if (!headers || typeof headers !== "object") return undefined;
|
|
1976
|
+
if (typeof (headers as { get?: unknown }).get === "function") {
|
|
1977
|
+
return (headers as { get(n: string): string | null }).get(name) ?? undefined;
|
|
1978
|
+
}
|
|
1979
|
+
const map = headers as Record<string, unknown>;
|
|
1980
|
+
const v = map[name.toLowerCase()] ?? map[name];
|
|
1981
|
+
if (typeof v === "string") return v;
|
|
1982
|
+
if (Array.isArray(v) && typeof v[0] === "string") return v[0];
|
|
1983
|
+
return undefined;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
/** Constant-time string equality (a length mismatch fails fast — length is not the secret). */
|
|
1987
|
+
function timingSafeEqualStr(a: string, b: string): boolean {
|
|
1988
|
+
const ab = new TextEncoder().encode(a);
|
|
1989
|
+
const bb = new TextEncoder().encode(b);
|
|
1990
|
+
if (ab.length !== bb.length) return false;
|
|
1991
|
+
let diff = 0;
|
|
1992
|
+
for (let i = 0; i < ab.length; i++) diff |= ab[i] ^ bb[i];
|
|
1993
|
+
return diff === 0;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1681
1996
|
async function resolvePolicy<User>(
|
|
1682
1997
|
policy: RindleApiServerOptions<User>["materializationPolicy"],
|
|
1683
1998
|
input: QueryLeaseRequest<User>,
|