@displayxr/inline3d 1.47.6 → 1.49.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,320 @@
1
+ #!/usr/bin/env node
2
+ // signaling/dev-server.mjs — a zero-dependency Node server for the `dxr-signal/1` protocol, for
3
+ // local development and the test suite. Same room logic (./room.mjs) and the same admission —
4
+ // keys, rate limits, room leases, the TURN budget (./gate.mjs) — as the Cloudflare Worker; only
5
+ // the socket plumbing and where the counters live differ. NOT for production: one process, rooms
6
+ // and counters in memory, no TLS.
7
+ //
8
+ // node signaling/dev-server.mjs [--port 8787] [--cap 4] [--keys keys.json]
9
+ // → ws://localhost:8787/v1/connect?k=<sha256(room)>[&key=pk_…]
10
+ //
11
+ // Optional TURN (same variables as the Worker): TURN_KEY_ID, TURN_KEY_API_TOKEN, TURN_TTL. The
12
+ // limit and budget variables (README.md) are read from the environment too; `--keys` is a JSON
13
+ // object `{ "pk_…": { "origins": [...] } }` for keyed sessions.
14
+ //
15
+ // The WebSocket framing below is the minimal RFC 6455 server subset: the upgrade handshake,
16
+ // masked client frames (text, continuation, ping/pong, close), unmasked server text frames. That
17
+ // is all a JSON signalling channel needs, and it keeps `npm install` out of a dev loop.
18
+
19
+ import { createServer } from 'node:http';
20
+ import { createHash } from 'node:crypto';
21
+ import { readFileSync } from 'node:fs';
22
+ import { pathToFileURL } from 'node:url';
23
+ import { Room, DEFAULT_ROOM_CAP, MAX_MESSAGE_BYTES, mintTurnCredentials, refuseConn } from './room.mjs';
24
+ import { Meter, limitsFromEnv } from './limits.mjs';
25
+ import { TurnBudget, budgetFromEnv } from './turn-budget.mjs';
26
+ import { mapKeyStore } from './keys.mjs';
27
+ import { createGate } from './gate.mjs';
28
+
29
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
30
+ const KEY_RE = /^[0-9a-f]{64}$/;
31
+ const IDLE_MS = 60_000;
32
+
33
+ /**
34
+ * Start a dev server. Resolves once listening.
35
+ * @param {{port?: number, host?: string, cap?: number, env?: object, keys?: Map<string, object>|object,
36
+ * turn?: ((session: object|null) => Promise<object[]|null>)|null, blocklist?: Set<string>,
37
+ * log?: (s: string) => void}} [opts]
38
+ * `turn`: a minter to use instead of Cloudflare (tests: fake TURN without a token).
39
+ * @returns {Promise<{port: number, url: string, rooms: Map<string, Room>, budget: TurnBudget, meters: Map<string, Meter>, close(): Promise<void>}>}
40
+ */
41
+ export function startDevServer({ port = 8787, host = '127.0.0.1', cap = DEFAULT_ROOM_CAP, env = process.env, keys = null, turn = undefined, blocklist = null, log = () => {} } = {}) {
42
+ /** @type {Map<string, Room>} */
43
+ const rooms = new Map();
44
+ const timers = new Map(); // room key -> lifetime timer
45
+ const sockets = new Set();
46
+ const raw = new Set(); // every upgraded TCP socket, destroyed on shutdown
47
+ const budget = new TurnBudget({ cfg: budgetFromEnv(env) });
48
+ /** @type {Map<string, Meter>} */
49
+ const meters = new Map();
50
+ const meterOf = (subject) => {
51
+ let m = meters.get(subject);
52
+ if (!m) meters.set(subject, (m = new Meter()));
53
+ return m;
54
+ };
55
+ const gate = createGate({
56
+ limits: limitsFromEnv(env),
57
+ keys: keys ? mapKeyStore(keys) : null,
58
+ budget: { snapshot: () => budget.snapshot(), cfg: budget.cfg },
59
+ meters: { exec: (subject, cmd) => meterOf(subject).exec(cmd) },
60
+ blocklist: blocklist ? { has: (ip) => blocklist.has(ip) } : null,
61
+ salt: env.RATE_SALT || '',
62
+ });
63
+ const cfTurn = env.TURN_KEY_ID && env.TURN_KEY_API_TOKEN;
64
+ const minter =
65
+ turn !== undefined
66
+ ? turn
67
+ : cfTurn
68
+ ? (s) => mintTurnCredentials(env, undefined, { ttl: s && s.turn && s.turn.ttl > 0 ? s.turn.ttl : undefined, customIdentifier: (s && s.key) || 'anon' })
69
+ : null;
70
+ const ice = minter
71
+ ? async (s) => {
72
+ const r = await minter(s);
73
+ if (r) budget.recordMint(s ? s.key : null, s && s.turn && s.turn.ttl > 0 ? s.turn.ttl : parseInt(env.TURN_TTL || '3600', 10) || 3600);
74
+ return r;
75
+ }
76
+ : null;
77
+
78
+ const server = createServer((req, res) => {
79
+ const u = new URL(req.url, 'http://x');
80
+ if (u.pathname === '/' || u.pathname === '/health') {
81
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
82
+ res.end(JSON.stringify({ ok: true, protocol: 'dxr-signal/1', rooms: rooms.size, turn: ice ? budget.snapshot().status : 'unconfigured', keys: !!keys }));
83
+ return;
84
+ }
85
+ res.writeHead(426, { 'Content-Type': 'text/plain' });
86
+ res.end('WebSocket endpoint: /v1/connect?k=<sha256(room) hex>\n');
87
+ });
88
+
89
+ server.on('upgrade', (req, socket) => {
90
+ const u = new URL(req.url, 'http://x');
91
+ const key = u.searchParams.get('k') || '';
92
+ const wsKey = req.headers['sec-websocket-key'];
93
+ if (u.pathname !== '/v1/connect' || !KEY_RE.test(key) || !wsKey || String(req.headers.upgrade).toLowerCase() !== 'websocket') {
94
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
95
+ return;
96
+ }
97
+ const accept = createHash('sha1').update(wsKey + WS_GUID).digest('base64');
98
+ socket.write(
99
+ 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
100
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`
101
+ );
102
+ socket.setNoDelay(true);
103
+ raw.add(socket);
104
+ socket.on('close', () => raw.delete(socket));
105
+ let session = null;
106
+ let room = null;
107
+ let early = []; // messages that arrive while admission is still deciding (a join sent on open)
108
+ const conn = wrapSocket(socket, {
109
+ onText: (text) => (room ? room.onMessage(conn, text).catch(() => {}) : early && early.push(text)),
110
+ onClose: () => {
111
+ sockets.delete(conn);
112
+ if (room) {
113
+ room.onClose(conn);
114
+ if (room.size === 0 && rooms.get(key) === room) rooms.delete(key);
115
+ }
116
+ if (session) gate.release(session).catch(() => {});
117
+ },
118
+ });
119
+ sockets.add(conn);
120
+ gate
121
+ .admit({ ip: socket.remoteAddress || '', origin: String(req.headers.origin || ''), key: u.searchParams.get('key') || null, roomKey: key, wantTurn: !!ice })
122
+ .then((a) => {
123
+ if (!a.ok) {
124
+ log(`refuse ${a.code} k=${key.slice(0, 8)}…`);
125
+ return refuseConn(conn, a);
126
+ }
127
+ session = a.session;
128
+ budget.recordJoin(session.key);
129
+ room = rooms.get(key);
130
+ if (!room) {
131
+ const r = new Room({
132
+ key,
133
+ cap,
134
+ iceServers: ice,
135
+ hooks: {
136
+ onFirst: (s) => {
137
+ clearTimeout(timers.get(key));
138
+ const ttlS = s && s.roomTtlS > 0 ? s.roomTtlS : limitsFromEnv(env).anon.roomTtlS;
139
+ const t = setTimeout(() => r.expire(), ttlS * 1000);
140
+ t.unref();
141
+ timers.set(key, t);
142
+ },
143
+ onEmpty: () => {
144
+ clearTimeout(timers.get(key));
145
+ timers.delete(key);
146
+ },
147
+ },
148
+ });
149
+ rooms.set(key, r);
150
+ room = r;
151
+ }
152
+ room.attach(conn, session);
153
+ log(`connect k=${key.slice(0, 8)}… ${session.tier}${session.key ? ' ' + session.key : ''} (${rooms.size} rooms)`);
154
+ const replay = early;
155
+ early = null;
156
+ for (const text of replay) room.onMessage(conn, text).catch(() => {});
157
+ })
158
+ .catch(() => {
159
+ early = null;
160
+ refuseConn(conn, { code: 'bad-message', message: 'admission failed', closeCode: 4000 });
161
+ });
162
+ });
163
+
164
+ return new Promise((resolve, reject) => {
165
+ server.once('error', reject);
166
+ server.listen(port, host, () => {
167
+ const addr = server.address();
168
+ const p = typeof addr === 'object' && addr ? addr.port : port;
169
+ resolve({
170
+ port: p,
171
+ url: `ws://${host === '0.0.0.0' ? 'localhost' : host}:${p}`,
172
+ rooms,
173
+ budget,
174
+ meters,
175
+ close: () =>
176
+ new Promise((done) => {
177
+ for (const t of timers.values()) clearTimeout(t);
178
+ for (const c of sockets) c.close(1001, 'server shutting down');
179
+ for (const s of raw) s.destroy();
180
+ server.close(() => done());
181
+ }),
182
+ });
183
+ });
184
+ });
185
+ }
186
+
187
+ /** Wrap a raw upgraded socket as a `{send(obj), close(code, reason)}` connection. */
188
+ function wrapSocket(socket, { onText, onClose }) {
189
+ let buf = Buffer.alloc(0);
190
+ let frag = null; // continuation assembly: { chunks: Buffer[] }
191
+ let closed = false;
192
+ let idle = setTimeout(() => conn.close(1001, 'idle'), IDLE_MS);
193
+ const touch = () => {
194
+ clearTimeout(idle);
195
+ // Never re-armed after close: the client's close-frame reply still arrives as data.
196
+ if (!closed) idle = setTimeout(() => conn.close(1001, 'idle'), IDLE_MS);
197
+ };
198
+
199
+ const conn = {
200
+ send(obj) {
201
+ if (closed) return;
202
+ socket.write(frame(0x1, Buffer.from(JSON.stringify(obj))));
203
+ },
204
+ close(code = 1000, reason = '') {
205
+ if (closed) return;
206
+ closed = true;
207
+ clearTimeout(idle);
208
+ const r = Buffer.from(String(reason).slice(0, 100));
209
+ const p = Buffer.alloc(2 + r.length);
210
+ p.writeUInt16BE(code, 0);
211
+ r.copy(p, 2);
212
+ try {
213
+ socket.end(frame(0x8, p));
214
+ } catch {
215
+ /* ignore */
216
+ }
217
+ // Give the client a moment to answer the close frame, then drop the TCP socket regardless.
218
+ setTimeout(() => socket.destroy(), 1000).unref();
219
+ onClose();
220
+ },
221
+ };
222
+
223
+ socket.on('data', (chunk) => {
224
+ if (closed) return;
225
+ touch();
226
+ buf = Buffer.concat([buf, chunk]);
227
+ for (;;) {
228
+ if (buf.length < 2) return;
229
+ const fin = (buf[0] & 0x80) !== 0;
230
+ const op = buf[0] & 0x0f;
231
+ const masked = (buf[1] & 0x80) !== 0;
232
+ let len = buf[1] & 0x7f;
233
+ let off = 2;
234
+ if (len === 126) {
235
+ if (buf.length < 4) return;
236
+ len = buf.readUInt16BE(2);
237
+ off = 4;
238
+ } else if (len === 127) {
239
+ if (buf.length < 10) return;
240
+ const big = buf.readBigUInt64BE(2);
241
+ if (big > BigInt(MAX_MESSAGE_BYTES * 2)) return conn.close(1009, 'too big');
242
+ len = Number(big);
243
+ off = 10;
244
+ }
245
+ if (!masked) return conn.close(1002, 'client frames must be masked');
246
+ if (buf.length < off + 4 + len) return;
247
+ const mask = buf.subarray(off, off + 4);
248
+ const payload = Buffer.from(buf.subarray(off + 4, off + 4 + len));
249
+ for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
250
+ buf = buf.subarray(off + 4 + len);
251
+
252
+ if (op === 0x8) return conn.close(1000, '');
253
+ if (op === 0x9) {
254
+ socket.write(frame(0xa, payload));
255
+ continue;
256
+ }
257
+ if (op === 0xa) continue;
258
+ if (op === 0x1 || op === 0x0) {
259
+ if (op === 0x1) frag = { chunks: [] };
260
+ if (!frag) return conn.close(1002, 'unexpected continuation');
261
+ frag.chunks.push(payload);
262
+ const total = frag.chunks.reduce((n, c) => n + c.length, 0);
263
+ if (total > MAX_MESSAGE_BYTES * 2) return conn.close(1009, 'too big');
264
+ if (fin) {
265
+ const text = Buffer.concat(frag.chunks).toString('utf8');
266
+ frag = null;
267
+ onText(text);
268
+ }
269
+ continue;
270
+ }
271
+ return conn.close(1003, 'unsupported frame');
272
+ }
273
+ });
274
+ socket.on('close', () => {
275
+ clearTimeout(idle);
276
+ if (!closed) {
277
+ closed = true;
278
+ clearTimeout(idle);
279
+ onClose();
280
+ }
281
+ });
282
+ socket.on('error', () => socket.destroy());
283
+ return conn;
284
+ }
285
+
286
+ function frame(op, payload) {
287
+ const len = payload.length;
288
+ let head;
289
+ if (len < 126) {
290
+ head = Buffer.from([0x80 | op, len]);
291
+ } else if (len < 65536) {
292
+ head = Buffer.alloc(4);
293
+ head[0] = 0x80 | op;
294
+ head[1] = 126;
295
+ head.writeUInt16BE(len, 2);
296
+ } else {
297
+ head = Buffer.alloc(10);
298
+ head[0] = 0x80 | op;
299
+ head[1] = 127;
300
+ head.writeBigUInt64BE(BigInt(len), 2);
301
+ }
302
+ return Buffer.concat([head, payload]);
303
+ }
304
+
305
+ // CLI
306
+ // pathToFileURL: a Windows argv path (C:\…) never string-matches file:///C:/…
307
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
308
+ const arg = (name, dflt) => {
309
+ const i = process.argv.indexOf(`--${name}`);
310
+ return i > 0 ? process.argv[i + 1] : dflt;
311
+ };
312
+ const port = parseInt(arg('port', process.env.PORT || '8787'), 10);
313
+ const cap = parseInt(arg('cap', '4'), 10);
314
+ const host = arg('host', '127.0.0.1');
315
+ const keysFile = arg('keys', '');
316
+ const keys = keysFile ? JSON.parse(readFileSync(keysFile, 'utf8')) : null;
317
+ startDevServer({ port, cap, host, keys, log: (s) => console.log(`[dxr-signal] ${s}`) }).then((s) => {
318
+ console.log(`[dxr-signal] dev server on ${s.url}/v1/connect (cap ${cap}${process.env.TURN_KEY_ID ? ', TURN on' : ''}${keys ? `, ${Object.keys(keys).length} keys` : ''})`);
319
+ });
320
+ }
@@ -0,0 +1,112 @@
1
+ // signaling/gate.mjs — admission for one `dxr-signal/1` connect: key + origin (keys.mjs), the
2
+ // blocklist, join rate + room leases (limits.mjs), and the TURN decision (turn-budget.mjs).
3
+ // Shared verbatim by the Cloudflare Worker (worker.mjs) and the Node dev server (dev-server.mjs);
4
+ // the two only differ in where the counters live (Durable Objects vs a Map), which they inject
5
+ // as `meters` / `budget`.
6
+ //
7
+ // Order of checks, and why: key/origin first (a wrong key is a configuration error, answered
8
+ // before any counter moves) → blocklist → join rate on the IP (a flood is refused before it can
9
+ // ask about rooms) → room lease on the IP (anonymous) or the key (keyed) → TURN policy from the
10
+ // budget, then the per-IP mint rate. Every refusal is a `{code, message, closeCode}` the caller
11
+ // turns into one `error` message + a close, so a client always learns WHY (never a silent hang).
12
+
13
+ import { limitsFor, CLOSE_CODES, HOUR_MS, subjectForIp } from './limits.mjs';
14
+ import { resolveKey } from './keys.mjs';
15
+ import { turnPolicy } from './turn-budget.mjs';
16
+
17
+ /**
18
+ * @typedef {object} Session what a connect is admitted AS (carried on the socket, never to the client verbatim)
19
+ * @property {'anon'|'key'} tier
20
+ * @property {string|null} key the key id (billing identity), null for anonymous
21
+ * @property {string} ipSubject salted IP hash subject (`ip:…`)
22
+ * @property {{ mint: boolean, ttl: number, status: string, reason?: string }} turn
23
+ * @property {number} roomTtlS room lifetime for a room this session opens
24
+ * @property {{ subject: string, roomKey: string }[]} leases what to release on close
25
+ */
26
+
27
+ /**
28
+ * @param {object} o
29
+ * @param {{anon: object, key: object}} o.limits from limitsFromEnv()
30
+ * @param {{get(id: string): Promise<object|null>}|null} o.keys key store (null = keys unsupported → every key is 'bad-key')
31
+ * @param {{ snapshot(): Promise<{fraction: number, status: string}>|{fraction: number, status: string}, cfg: object }} o.budget
32
+ * @param {{ exec(subject: string, cmd: object): Promise<any>|any }} o.meters
33
+ * @param {{ has(ip: string): Promise<boolean>|boolean }|null} [o.blocklist]
34
+ * @param {string} [o.salt]
35
+ */
36
+ export function createGate({ limits, keys, budget, meters, blocklist = null, salt = '' }) {
37
+ const refuse = (code, message, retryMs = 0) => ({ ok: false, code, message, closeCode: CLOSE_CODES[code] || 4000, retryMs });
38
+
39
+ return {
40
+ /**
41
+ * @param {{ ip: string, origin: string, key: string|null, roomKey: string, wantTurn?: boolean }} req
42
+ * `wantTurn` false = the server has no TURN configured at all (skip the budget entirely)
43
+ * @returns {Promise<{ ok: true, session: Session } | { ok: false, code: string, message: string, closeCode: number, retryMs: number }>}
44
+ */
45
+ async admit({ ip, origin, key: keyParam, roomKey, wantTurn = true }) {
46
+ const k = await resolveKey(keyParam || null, keys, origin || '');
47
+ if (!k.ok) return refuse(k.code, k.message);
48
+ if (blocklist && (await blocklist.has(ip || ''))) return refuse('blocked', 'this address is blocked (abuse@displayxr.org)');
49
+ const tier = k.tier;
50
+ const rec = tier === 'key' ? k.key : null;
51
+ const lim = limitsFor(tier, rec, limits);
52
+ const ipSubject = await subjectForIp(ip || '', salt);
53
+ const leaseTtlMs = lim.roomTtlS * 1000 + HOUR_MS; // a lease outlives the room it is for
54
+ /** @type {Session} */
55
+ const session = { tier, key: rec ? rec.id : null, ipSubject, turn: { mint: false, ttl: 0, status: 'off', reason: 'unconfigured' }, roomTtlS: lim.roomTtlS, leases: [] };
56
+
57
+ // Join rate is always per IP (the tier only picks the number); the room lease is per key
58
+ // for keyed sessions (a customer's quota is theirs, not their users' IPs') and per IP otherwise.
59
+ const anonRooms = tier === 'anon' ? lim.rooms : undefined;
60
+ const a = await meters.exec(ipSubject, { op: 'admit', roomKey, tierName: tier, joinsPerMin: lim.joinsPerMin, rooms: anonRooms, leaseTtlMs });
61
+ if (!a.ok) return refuse(a.code, a.message, a.retryMs);
62
+ if (anonRooms !== undefined) session.leases.push({ subject: ipSubject, roomKey });
63
+ if (tier === 'key') {
64
+ const keySubject = `key:${rec.id}`;
65
+ const l = await meters.exec(keySubject, { op: 'acquire', roomKey, limit: lim.rooms, ttlMs: leaseTtlMs });
66
+ if (!l.ok) return refuse('quota', `this key has too many open rooms (${l.limit})`);
67
+ session.leases.push({ subject: keySubject, roomKey });
68
+ }
69
+
70
+ if (wantTurn) {
71
+ const snap = await budget.snapshot();
72
+ const pol = turnPolicy(snap, tier, lim, budget.cfg);
73
+ session.turn = { mint: pol.mint, ttl: pol.ttl, status: pol.status };
74
+ if (pol.reason) session.turn.reason = pol.reason;
75
+ if (pol.mint) {
76
+ // Mint rate per IP per hour (RFC §5b): over it, the session still joins — STUN-only.
77
+ const m = await meters.exec(ipSubject, { op: 'hit', name: `mints.${tier}`, windowMs: HOUR_MS, limit: pol.mintsPerHour });
78
+ if (!m.ok) session.turn = { mint: false, ttl: 0, status: pol.status, reason: 'rate' };
79
+ }
80
+ }
81
+ return { ok: true, session };
82
+ },
83
+
84
+ /** Give every lease of a session back (socket closed). Safe to call twice. */
85
+ async release(session) {
86
+ if (!session || !Array.isArray(session.leases)) return;
87
+ const leases = session.leases;
88
+ session.leases = [];
89
+ for (const { subject, roomKey } of leases) {
90
+ try {
91
+ await meters.exec(subject, { op: 'release', roomKey });
92
+ } catch {
93
+ /* the lease expires on its own */
94
+ }
95
+ }
96
+ },
97
+ };
98
+ }
99
+
100
+ /** A blocklist over a KV namespace (`block:<ip>` present = blocked) — or null. */
101
+ export function kvBlocklist(kv) {
102
+ if (!kv) return null;
103
+ return {
104
+ async has(ip) {
105
+ try {
106
+ return !!(ip && (await kv.get(`block:${ip}`, { cacheTtl: 60 })));
107
+ } catch {
108
+ return false;
109
+ }
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,123 @@
1
+ // signaling/keys.mjs — publishable keys for the hosted `dxr-signal/1` service (RFC 0003 §5a).
2
+ //
3
+ // A key is PUBLIC: it sits in page source, so it is attribution + a quota bucket + an origin
4
+ // binding that browsers enforce, never a secret. The record lives server-side (a KV namespace on
5
+ // Cloudflare: `key:<id>` → JSON; a Map on the dev server), is issued by hand (tools/signal-keys.mjs),
6
+ // and is checked on every connect against the request's `Origin`. Every session carries its key
7
+ // id so usage is attributable per key from day one (billing identity; billing itself is OFF).
8
+
9
+ /** `pk_` + 20–40 base62 chars. The id IS the key (there is no secret half). */
10
+ export const KEY_RE = /^pk_[A-Za-z0-9]{20,40}$/;
11
+
12
+ /**
13
+ * @typedef {object} KeyRecord
14
+ * @property {string} id the key itself (`pk_…`)
15
+ * @property {string[]} origins allowed page origins; `*` suffix wildcards one label level
16
+ * (`https://*.example.com`), `http://localhost` matches any port
17
+ * @property {string} [note] who / what it was issued for (never shown to clients)
18
+ * @property {string} [createdAt] ISO date
19
+ * @property {boolean} [revoked] a revoked key is refused like an unknown one
20
+ * @property {Partial<import('./limits.mjs').TierLimits>} [limits] per-key overrides
21
+ */
22
+
23
+ /** Generate a new key id. `random` = bytes → used in Node and in the Worker alike. */
24
+ export function newKeyId(bytes = globalThis.crypto.getRandomValues(new Uint8Array(24))) {
25
+ const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
26
+ let s = '';
27
+ for (const b of bytes) s += A[b % 62];
28
+ return 'pk_' + s.slice(0, 24);
29
+ }
30
+
31
+ /** Parse + validate a stored record; null if it is not a usable key record. */
32
+ export function parseKeyRecord(id, raw) {
33
+ if (!KEY_RE.test(id || '')) return null;
34
+ let rec = raw;
35
+ if (typeof raw === 'string') {
36
+ try {
37
+ rec = JSON.parse(raw);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+ if (!rec || typeof rec !== 'object') return null;
43
+ const origins = Array.isArray(rec.origins) ? rec.origins.filter((o) => typeof o === 'string' && o).map(normalizeOrigin) : [];
44
+ return {
45
+ id,
46
+ origins,
47
+ note: typeof rec.note === 'string' ? rec.note : '',
48
+ createdAt: typeof rec.createdAt === 'string' ? rec.createdAt : '',
49
+ revoked: !!rec.revoked,
50
+ limits: rec.limits && typeof rec.limits === 'object' ? { ...rec.limits } : undefined,
51
+ };
52
+ }
53
+
54
+ /** Lowercase scheme+host, strip a trailing slash and a default port. */
55
+ export function normalizeOrigin(o) {
56
+ let s = String(o || '')
57
+ .trim()
58
+ .toLowerCase()
59
+ .replace(/\/+$/, '');
60
+ s = s.replace(/^(https):\/\/([^/:]+):443$/, '$1://$2').replace(/^(http):\/\/([^/:]+):80$/, '$1://$2');
61
+ return s;
62
+ }
63
+
64
+ /**
65
+ * Does `origin` (the request header) match one allowed pattern?
66
+ * - exact: `https://app.example.com`
67
+ * - one-label wildcard: `https://*.example.com` (matches `a.example.com`, not `example.com`, not `a.b.example.com`)
68
+ * - `http://localhost` / `https://localhost` / `http://127.0.0.1`: any port (dev pages)
69
+ * - `*`: any origin (a key used purely for attribution / headroom)
70
+ * An ABSENT Origin header (a non-browser client) never matches a key with an origin list.
71
+ */
72
+ export function originAllowed(origin, patterns) {
73
+ const o = normalizeOrigin(origin);
74
+ if (!o || o === 'null') return patterns.includes('*');
75
+ for (const raw of patterns) {
76
+ const p = normalizeOrigin(raw);
77
+ if (p === '*' || p === o) return true;
78
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)$/.test(p) && (o === p || o.startsWith(p + ':'))) return true;
79
+ const m = /^(https?):\/\/\*\.(.+)$/.exec(p);
80
+ if (m) {
81
+ const n = new RegExp(`^${m[1]}:\\/\\/[a-z0-9-]+\\.${m[2].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(:\\d+)?$`);
82
+ if (n.test(o)) return true;
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+
88
+ /**
89
+ * Resolve the `key` query parameter against the key store for one connect.
90
+ * @param {string|null} param the raw `?key=` value ('' / null = anonymous)
91
+ * @param {{ get(id: string): Promise<KeyRecord|null> }} store
92
+ * @param {string} origin the request's Origin header
93
+ * @returns {Promise<{ ok: true, tier: 'anon' } | { ok: true, tier: 'key', key: KeyRecord } | { ok: false, code: 'bad-key'|'origin-not-allowed', message: string }>}
94
+ */
95
+ export async function resolveKey(param, store, origin) {
96
+ if (!param) return { ok: true, tier: 'anon' };
97
+ if (!KEY_RE.test(param)) return { ok: false, code: 'bad-key', message: 'malformed key (pk_…)' };
98
+ const rec = store ? await store.get(param) : null;
99
+ if (!rec || rec.revoked) return { ok: false, code: 'bad-key', message: 'unknown or revoked key' };
100
+ if (!originAllowed(origin, rec.origins)) return { ok: false, code: 'origin-not-allowed', message: `key is not allowed from origin "${origin || '(none)'}"` };
101
+ return { ok: true, tier: 'key', key: rec };
102
+ }
103
+
104
+ /** A key store over a Cloudflare KV namespace (`key:<id>`), with the edge cache KV offers. */
105
+ export function kvKeyStore(kv, { cacheTtl = 60 } = {}) {
106
+ if (!kv) return null;
107
+ return {
108
+ async get(id) {
109
+ try {
110
+ const raw = await kv.get(`key:${id}`, { cacheTtl });
111
+ return raw ? parseKeyRecord(id, raw) : null;
112
+ } catch {
113
+ return null; // a KV hiccup refuses the key (fail closed) rather than crashing the connect
114
+ }
115
+ },
116
+ };
117
+ }
118
+
119
+ /** A key store over a plain Map / object (dev server, tests). */
120
+ export function mapKeyStore(map) {
121
+ const get = (id) => (map instanceof Map ? map.get(id) : map && map[id]);
122
+ return { async get(id) { const r = get(id); return r ? parseKeyRecord(id, r) : null; } };
123
+ }