@rindle/room 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.sh +53 -0
- package/dist/authority.d.ts +57 -0
- package/dist/authority.d.ts.map +1 -0
- package/dist/authority.js +73 -0
- package/dist/authority.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +64 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +53 -0
- package/dist/journal.js.map +1 -0
- package/dist/mutation-tx.d.ts +79 -0
- package/dist/mutation-tx.d.ts.map +1 -0
- package/dist/mutation-tx.js +207 -0
- package/dist/mutation-tx.js.map +1 -0
- package/dist/shell.d.ts +134 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +1589 -0
- package/dist/shell.js.map +1 -0
- package/dist/token.d.ts +89 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +158 -0
- package/dist/token.js.map +1 -0
- package/dist/wasm.d.ts +7 -0
- package/dist/wasm.d.ts.map +1 -0
- package/dist/wasm.js +29 -0
- package/dist/wasm.js.map +1 -0
- package/package.json +71 -0
- package/pkg/rindle_room.d.ts +345 -0
- package/pkg/rindle_room.js +924 -0
- package/pkg/rindle_room_bg.wasm +0 -0
- package/pkg/rindle_room_bg.wasm.d.ts +45 -0
- package/src/authority.ts +127 -0
- package/src/index.ts +49 -0
- package/src/journal.ts +110 -0
- package/src/mutation-tx.ts +275 -0
- package/src/shell.ts +1859 -0
- package/src/token.ts +219 -0
- package/src/wasm.ts +32 -0
package/src/token.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The room's **self-authorizing signed lease token** (RINDLE-REALTIME-DESIGN.md §10.1).
|
|
3
|
+
*
|
|
4
|
+
* The API server is the authority (§4): it authenticates the user, resolves the named
|
|
5
|
+
* query to an approved AST, and signs this token. The token then IS the lease — the
|
|
6
|
+
* room materializes on first presentation, so no pre-placement `/materialize` control
|
|
7
|
+
* call ever touches it (the §10.1 inversion; on the DO shell the Worker verifies the
|
|
8
|
+
* same signature statelessly before `get(id)`).
|
|
9
|
+
*
|
|
10
|
+
* Shape: `rt1.<base64url(payload)>.<base64url(hmac-sha256(prefix.payload))>` with
|
|
11
|
+
* payload `{ v: 1, doc, ast, sub, iat, exp, kid }`:
|
|
12
|
+
*
|
|
13
|
+
* - `doc` — the room/document id the token authorizes (a token for one room presented
|
|
14
|
+
* to another is refused);
|
|
15
|
+
* - `ast` — the approved wire `Ast` (the client never composes this; §4's "ASTs never
|
|
16
|
+
* cross the public wire" holds because the token is opaque TO THE CLIENT — it carries
|
|
17
|
+
* it, it cannot mint or alter it);
|
|
18
|
+
* - `sub` — the subject (user id): the revocation key (§4.1);
|
|
19
|
+
* - `iat`/`exp` — issued-at / expiry, ms epoch. Renewal is re-authorization: clients
|
|
20
|
+
* obtain a fresh token through the API server, never extend this one. `iat` is what
|
|
21
|
+
* lets a revocation refuse pre-revocation tokens while a genuine re-grant (a newer
|
|
22
|
+
* token) passes immediately;
|
|
23
|
+
* - `kid` — which shared secret signed it (rotation).
|
|
24
|
+
*
|
|
25
|
+
* HMAC via WebCrypto (`crypto.subtle`) so the exact same code verifies in Node (the
|
|
26
|
+
* test shell) and in a Cloudflare Worker/DO (P4) — no `node:crypto` import.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface RoomTokenPayload {
|
|
30
|
+
v: 1;
|
|
31
|
+
doc: string;
|
|
32
|
+
ast: unknown;
|
|
33
|
+
sub: string;
|
|
34
|
+
iat: number;
|
|
35
|
+
exp: number;
|
|
36
|
+
kid: string;
|
|
37
|
+
/** A short fingerprint of the room's compiled scope specs at mint time
|
|
38
|
+
* ({@link scopeSpecsHash}). Advisory, not a credential: the room's §3.3 gate is the
|
|
39
|
+
* contract regardless. It lets the shell detect SCOPE SKEW — a room profile edited
|
|
40
|
+
* while a room is already live arms the gate with the OLD specs (a one-shot at boot)
|
|
41
|
+
* while fresh leases prove against the NEW ones, so every routed write silently
|
|
42
|
+
* deopt-loops. Optional so a pre-stamp api-server / older token still verifies. */
|
|
43
|
+
scopesHash?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MintRoomTokenOptions {
|
|
47
|
+
doc: string;
|
|
48
|
+
ast: unknown;
|
|
49
|
+
/** The subject (user id) this token authorizes — the §4.1 revocation key. */
|
|
50
|
+
sub: string;
|
|
51
|
+
/** Key id + its secret (utf-8; give every room the same `keys` map). */
|
|
52
|
+
kid: string;
|
|
53
|
+
key: string;
|
|
54
|
+
/** Expiry, ms from `now`. Keep short (minutes) — the §4.1 TTL backstop. */
|
|
55
|
+
ttlMs: number;
|
|
56
|
+
/** Mint time; defaults to `Date.now()`. Injectable for tests. */
|
|
57
|
+
now?: number;
|
|
58
|
+
/** The room's {@link scopeSpecsHash} for the profile this lease serves — stamped so the
|
|
59
|
+
* shell can flag scope skew (see {@link RoomTokenPayload.scopesHash}). Omit to not stamp. */
|
|
60
|
+
scopesHash?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PREFIX = "rt1";
|
|
64
|
+
|
|
65
|
+
/** Canonical JSON: object keys sorted recursively (arrays keep order), so structurally
|
|
66
|
+
* identical specs serialize identically regardless of key INSERTION order across code
|
|
67
|
+
* versions — a cosmetic reorder must not read as a scope change. `undefined`-valued keys
|
|
68
|
+
* are dropped, matching `JSON.stringify` and the `footprintWhere?`/`where?` optionals. */
|
|
69
|
+
function canonicalJson(v: unknown): string {
|
|
70
|
+
if (v === undefined) return "null";
|
|
71
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v);
|
|
72
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
|
|
73
|
+
const o = v as Record<string, unknown>;
|
|
74
|
+
const keys = Object.keys(o)
|
|
75
|
+
.filter((k) => o[k] !== undefined)
|
|
76
|
+
.sort();
|
|
77
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A stable short fingerprint of the compiled scope specs — the scope-skew tripwire.
|
|
81
|
+
* NOT security (the gate re-proves every write): FNV-1a-32 over the {@link canonicalJson}
|
|
82
|
+
* form, 8 hex chars. Both the api-server (stamping the lease token) and the room shell
|
|
83
|
+
* (hashing the boot-wire scopes it armed the gate with) run this over the SAME compiler
|
|
84
|
+
* output, so an unchanged profile ⇒ equal hash and a profile edited under a live room ⇒
|
|
85
|
+
* mismatch. A collision only costs a missed diagnostic, never correctness. Accepts either
|
|
86
|
+
* wire's spec array (`RoomScopeSpec[]` / `RoomTableSpec[]` — structurally identical). */
|
|
87
|
+
export function scopeSpecsHash(specs: readonly unknown[]): string {
|
|
88
|
+
const json = canonicalJson(specs);
|
|
89
|
+
let h = 0x811c9dc5;
|
|
90
|
+
for (let i = 0; i < json.length; i++) {
|
|
91
|
+
h ^= json.charCodeAt(i);
|
|
92
|
+
h = Math.imul(h, 0x01000193);
|
|
93
|
+
}
|
|
94
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function b64url(bytes: Uint8Array): string {
|
|
98
|
+
let bin = "";
|
|
99
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
100
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Return type inferred (`Uint8Array<ArrayBuffer>` under TS ≥5.7 libs) — an explicit
|
|
104
|
+
// `Uint8Array` annotation widens to `ArrayBufferLike` and fails `crypto.subtle`'s
|
|
105
|
+
// `BufferSource` under consumers compiling this source with newer lib types (the DO shell).
|
|
106
|
+
function unb64url(s: string) {
|
|
107
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
|
|
108
|
+
const out = new Uint8Array(bin.length);
|
|
109
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Return type inferred: this package's tsconfig has no DOM lib, so the WebCrypto
|
|
114
|
+
// interface names aren't ambient — but `crypto.subtle`'s own types carry through.
|
|
115
|
+
async function hmacKey(secret: string, usage: "sign" | "verify") {
|
|
116
|
+
return crypto.subtle.importKey(
|
|
117
|
+
"raw",
|
|
118
|
+
new TextEncoder().encode(secret),
|
|
119
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
120
|
+
false,
|
|
121
|
+
[usage],
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Sign a room lease token. This runs on the API-server side (or a test playing it). */
|
|
126
|
+
export async function mintRoomToken(opts: MintRoomTokenOptions): Promise<string> {
|
|
127
|
+
const now = opts.now ?? Date.now();
|
|
128
|
+
const payload: RoomTokenPayload = {
|
|
129
|
+
v: 1,
|
|
130
|
+
doc: opts.doc,
|
|
131
|
+
ast: opts.ast,
|
|
132
|
+
sub: opts.sub,
|
|
133
|
+
iat: now,
|
|
134
|
+
exp: now + opts.ttlMs,
|
|
135
|
+
kid: opts.kid,
|
|
136
|
+
// Stamped only when supplied — an unstamped mint keeps the pre-scopesHash byte shape.
|
|
137
|
+
...(opts.scopesHash !== undefined ? { scopesHash: opts.scopesHash } : {}),
|
|
138
|
+
};
|
|
139
|
+
const body = b64url(new TextEncoder().encode(JSON.stringify(payload)));
|
|
140
|
+
const signed = `${PREFIX}.${body}`;
|
|
141
|
+
const key = await hmacKey(opts.key, "sign");
|
|
142
|
+
const sig = new Uint8Array(
|
|
143
|
+
await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed)),
|
|
144
|
+
);
|
|
145
|
+
return `${signed}.${b64url(sig)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Why a token was refused. The `reason` is terse and safe to echo in a queryError. */
|
|
149
|
+
export class RoomTokenError extends Error {
|
|
150
|
+
readonly reason: string;
|
|
151
|
+
|
|
152
|
+
constructor(reason: string) {
|
|
153
|
+
super(`lease token refused: ${reason}`);
|
|
154
|
+
this.reason = reason;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface VerifyRoomTokenOptions {
|
|
159
|
+
/** The room's own doc id — a token for any other doc is refused. */
|
|
160
|
+
doc: string;
|
|
161
|
+
/** kid → shared secret. Unknown kids are refused (never "try them all"). */
|
|
162
|
+
keys: Record<string, string>;
|
|
163
|
+
/** Verification time; defaults to `Date.now()`. Injectable for tests. */
|
|
164
|
+
now?: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Verify a token's signature and claims; returns the payload (with the approved AST)
|
|
169
|
+
* or throws {@link RoomTokenError}. Signature is checked FIRST — no claim is trusted
|
|
170
|
+
* (not even `kid`'s existence beyond the key lookup) before the MAC passes.
|
|
171
|
+
*/
|
|
172
|
+
export async function verifyRoomToken(
|
|
173
|
+
token: string,
|
|
174
|
+
opts: VerifyRoomTokenOptions,
|
|
175
|
+
): Promise<RoomTokenPayload> {
|
|
176
|
+
const now = opts.now ?? Date.now();
|
|
177
|
+
const parts = token.split(".");
|
|
178
|
+
if (parts.length !== 3 || parts[0] !== PREFIX) {
|
|
179
|
+
throw new RoomTokenError("not a room token");
|
|
180
|
+
}
|
|
181
|
+
const [, body, sig] = parts;
|
|
182
|
+
|
|
183
|
+
// Parse only far enough to find `kid` (the signature covers everything, so a lying
|
|
184
|
+
// kid can only select a key that then fails the MAC).
|
|
185
|
+
let payload: RoomTokenPayload;
|
|
186
|
+
try {
|
|
187
|
+
payload = JSON.parse(new TextDecoder().decode(unb64url(body))) as RoomTokenPayload;
|
|
188
|
+
} catch {
|
|
189
|
+
throw new RoomTokenError("malformed payload");
|
|
190
|
+
}
|
|
191
|
+
const secret = typeof payload.kid === "string" ? opts.keys[payload.kid] : undefined;
|
|
192
|
+
if (secret === undefined) {
|
|
193
|
+
throw new RoomTokenError("unknown key id");
|
|
194
|
+
}
|
|
195
|
+
const key = await hmacKey(secret, "verify");
|
|
196
|
+
const ok = await crypto.subtle.verify(
|
|
197
|
+
"HMAC",
|
|
198
|
+
key,
|
|
199
|
+
unb64url(sig),
|
|
200
|
+
new TextEncoder().encode(`${PREFIX}.${body}`),
|
|
201
|
+
);
|
|
202
|
+
if (!ok) {
|
|
203
|
+
throw new RoomTokenError("bad signature");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (payload.v !== 1) throw new RoomTokenError("unknown version");
|
|
207
|
+
if (payload.doc !== opts.doc) throw new RoomTokenError("token is for another doc");
|
|
208
|
+
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
|
|
209
|
+
throw new RoomTokenError("missing subject");
|
|
210
|
+
}
|
|
211
|
+
if (typeof payload.exp !== "number" || now >= payload.exp) {
|
|
212
|
+
throw new RoomTokenError("expired lease");
|
|
213
|
+
}
|
|
214
|
+
if (typeof payload.iat !== "number") throw new RoomTokenError("missing iat");
|
|
215
|
+
if (payload.ast === undefined || payload.ast === null) {
|
|
216
|
+
throw new RoomTokenError("missing ast");
|
|
217
|
+
}
|
|
218
|
+
return payload;
|
|
219
|
+
}
|
package/src/wasm.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Loader for the room wasm artifact (pkg/, built by ./build.sh) — the same init-once
|
|
2
|
+
// pattern as @rindle/wasm: a `--target web` ESM artifact needs one explicit init before
|
|
3
|
+
// the classes are usable. Node reads the bytes from the package; a browser/Worker host
|
|
4
|
+
// fetches (or passes a precompiled module).
|
|
5
|
+
|
|
6
|
+
import init, { WasmRoom } from "../pkg/rindle_room.js";
|
|
7
|
+
|
|
8
|
+
export { WasmRoom };
|
|
9
|
+
|
|
10
|
+
let initialized: Promise<void> | null = null;
|
|
11
|
+
|
|
12
|
+
/** Initialize the room wasm module (idempotent). Call once before `WasmRoom.open`.
|
|
13
|
+
* Node: bytes are read from the package. Browser/bundler: the wasm is fetched. Pass
|
|
14
|
+
* `moduleOrPath` to override (a `WebAssembly.Module`, URL, or bytes). */
|
|
15
|
+
export function initRoomWasm(moduleOrPath?: unknown): Promise<void> {
|
|
16
|
+
if (!initialized) {
|
|
17
|
+
initialized = (async () => {
|
|
18
|
+
if (moduleOrPath !== undefined) {
|
|
19
|
+
await init({ module_or_path: moduleOrPath });
|
|
20
|
+
} else if (
|
|
21
|
+
(globalThis as { process?: { versions?: { node?: string } } }).process?.versions?.node
|
|
22
|
+
) {
|
|
23
|
+
const { readFile } = await import("node:fs/promises");
|
|
24
|
+
const bytes = await readFile(new URL("../pkg/rindle_room_bg.wasm", import.meta.url));
|
|
25
|
+
await init({ module_or_path: bytes });
|
|
26
|
+
} else {
|
|
27
|
+
await init();
|
|
28
|
+
}
|
|
29
|
+
})();
|
|
30
|
+
}
|
|
31
|
+
return initialized;
|
|
32
|
+
}
|