@byollm/relay 0.1.0-alpha.10

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 ADDED
@@ -0,0 +1,878 @@
1
+ // src/daemon-plane.ts
2
+ import {
3
+ ClaimRequest,
4
+ FetchRequest,
5
+ HeartbeatRequest,
6
+ PROTOCOL_VERSION,
7
+ ReleaseRequest,
8
+ ResultRequest,
9
+ RequestSignature,
10
+ keyId,
11
+ verifyRequest,
12
+ verifyPublicIdentity,
13
+ PublicIdentity
14
+ } from "@byollm/protocol";
15
+ import { randomUUID } from "crypto";
16
+ import { z } from "zod";
17
+
18
+ // src/state.ts
19
+ var AWAITING_PAYLOAD_MS = 1e4;
20
+ var RelayState = class {
21
+ #jobs = /* @__PURE__ */ new Map();
22
+ #presence = /* @__PURE__ */ new Map();
23
+ /**
24
+ * Take a stub for routing. The payload is not here and will not be.
25
+ *
26
+ * **Idempotent by job id, and that is a security property rather than a
27
+ * convenience.** Site-plane calls are authenticated by signature, and
28
+ * byollm_009 §4.2's argument for signing the request instead of a
29
+ * server-issued nonce rests entirely on every write being idempotent per the
30
+ * instance it names. This one was not: re-enqueueing a known id built a
31
+ * fresh `queued` job over the top of the old one, discarding a live claim,
32
+ * its lease and any payload the site had already sealed to a device. A
33
+ * replayed enqueue inside the two-minute freshness window was therefore a
34
+ * way to yank a job back from the machine running it — the `release` bug of
35
+ * §4.2, rediscovered on the other plane.
36
+ *
37
+ * So a known id returns what is already routing, unchanged. A site that
38
+ * restarts and republishes its queue is the normal case, and it must not
39
+ * disturb work in flight.
40
+ */
41
+ enqueue(input) {
42
+ const existing = this.#jobs.get(input.id);
43
+ if (existing) return existing;
44
+ const job = {
45
+ id: input.id,
46
+ siteId: input.siteId,
47
+ stub: input.stub,
48
+ state: "queued"
49
+ };
50
+ this.#jobs.set(job.id, job);
51
+ return job;
52
+ }
53
+ job(jobId) {
54
+ return this.#jobs.get(jobId);
55
+ }
56
+ jobs() {
57
+ return [...this.#jobs.values()];
58
+ }
59
+ /** Jobs a site must seal for, right now. */
60
+ awaiting(siteId) {
61
+ return this.jobs().filter(
62
+ (j) => j.siteId === siteId && j.state === "awaiting-payload"
63
+ );
64
+ }
65
+ /** Sealed results waiting to go home. */
66
+ finished(siteId) {
67
+ return this.jobs().filter(
68
+ (j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
69
+ );
70
+ }
71
+ seen(presence) {
72
+ const existing = this.#presence.get(presence.runnerId);
73
+ if (existing) {
74
+ existing.lastSeenAt = presence.lastSeenAt;
75
+ return existing;
76
+ }
77
+ const fresh = { ...presence, revoked: false };
78
+ this.#presence.set(presence.runnerId, fresh);
79
+ return fresh;
80
+ }
81
+ presence(runnerId) {
82
+ return this.#presence.get(runnerId);
83
+ }
84
+ everyone() {
85
+ return [...this.#presence.values()];
86
+ }
87
+ /**
88
+ * Return a job to the queue, forgetting the claim.
89
+ *
90
+ * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
91
+ * why the awaiting-payload timeout is cheap to fire: the worst case is that
92
+ * a device did nothing for ten seconds and another one gets a turn.
93
+ */
94
+ requeue(job) {
95
+ job.state = "queued";
96
+ delete job.claimedBy;
97
+ delete job.awaitingUntil;
98
+ delete job.payload;
99
+ }
100
+ /**
101
+ * Fire whatever the clock says is due, and report it.
102
+ *
103
+ * Returns the jobs it requeued so a caller can log or surface them — a
104
+ * timeout that fires invisibly is indistinguishable from a job that was
105
+ * never claimed, and those want very different debugging.
106
+ */
107
+ sweep(now) {
108
+ const requeued = [];
109
+ for (const job of this.#jobs.values()) {
110
+ if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
111
+ this.requeue(job);
112
+ requeued.push(job);
113
+ }
114
+ const lease = job.claimedBy;
115
+ if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
116
+ this.requeue(job);
117
+ requeued.push(job);
118
+ }
119
+ }
120
+ return requeued;
121
+ }
122
+ };
123
+
124
+ // src/daemon-plane.ts
125
+ var ok = (body) => ({ status: 200, body });
126
+ var fail = (status, error, message) => ({
127
+ status,
128
+ body: { error, message }
129
+ });
130
+ var DaemonPlane = class {
131
+ #deps;
132
+ constructor(deps) {
133
+ this.#deps = deps;
134
+ }
135
+ /**
136
+ * Pair a device — cloud_004 §3, the key-exchange moment.
137
+ *
138
+ * The relay hands back **the site's** public identity, taken from the
139
+ * consent projection, not its own. This is the sentence that makes hub mode
140
+ * safe: the daemon pins the party that will actually seal its work, so an
141
+ * envelope is verified against the site even though it arrived via us. A
142
+ * relay that substituted its own identity here could inject work — and would
143
+ * need a private key to do it, which is why it has none.
144
+ */
145
+ pair(body) {
146
+ const parsed = PairFixtureRequest.safeParse(body);
147
+ if (!parsed.success) {
148
+ return fail(400, "bad-request", "pair request failed schema validation");
149
+ }
150
+ if (!verifyPublicIdentity(parsed.data.device)) {
151
+ return fail(400, "bad-request", "the device identity is not consistent");
152
+ }
153
+ const consent = this.#deps.projection.consentFor(
154
+ parsed.data.owner,
155
+ this.#deps.siteId
156
+ );
157
+ if (!consent) {
158
+ return fail(403, "unauthorized", "no consent record for this user");
159
+ }
160
+ const site = this.#deps.projection.siteFor(this.#deps.siteId);
161
+ if (!site) {
162
+ return fail(403, "unauthorized", "this site is not registered");
163
+ }
164
+ const approved = this.#deps.projection.deviceByFingerprint(
165
+ parsed.data.device.identity
166
+ );
167
+ if (!approved) {
168
+ return fail(
169
+ 403,
170
+ "unauthorized",
171
+ "this device has not been approved by its owner"
172
+ );
173
+ }
174
+ if (approved.owner !== parsed.data.owner) {
175
+ return fail(403, "unauthorized", "this device belongs to another owner");
176
+ }
177
+ const runnerId = approved.runnerId;
178
+ this.#deps.state.seen({
179
+ runnerId,
180
+ owner: parsed.data.owner,
181
+ device: parsed.data.device,
182
+ lastSeenAt: this.#deps.now()
183
+ });
184
+ return ok({
185
+ protocolVersion: PROTOCOL_VERSION,
186
+ runnerId,
187
+ /** The *site's* key. See the note above — this is load-bearing. */
188
+ site: site.site
189
+ });
190
+ }
191
+ /** Every authenticated call: signature first, then consent, then work. */
192
+ #authed(input, body, schema, run, options = {}) {
193
+ const signature = RequestSignature.safeParse(input.signature);
194
+ if (!signature.success) {
195
+ return fail(401, "unauthorized", "this request is not signed");
196
+ }
197
+ const known = this.#deps.state.presence(signature.data.runnerId);
198
+ if (!known) {
199
+ return fail(401, "unauthorized", "this runner is not recognised");
200
+ }
201
+ const failure = verifyRequest({
202
+ identityPublic: known.device.identity,
203
+ endpoint: input.endpoint,
204
+ body: input.rawBody,
205
+ signature: signature.data,
206
+ now: this.#deps.now()
207
+ });
208
+ if (failure) return fail(401, "unauthorized", "signature check failed");
209
+ const revoked = this.#deps.projection.consentFor(known.owner, this.#deps.siteId) === null;
210
+ if (revoked && options.allowRevoked !== true) {
211
+ return fail(403, "revoked", "routing for this runner has been revoked");
212
+ }
213
+ known.lastSeenAt = this.#deps.now();
214
+ const parsed = schema.safeParse(body);
215
+ if (!parsed.success || parsed.data === void 0) {
216
+ return fail(400, "bad-request", "request failed schema validation");
217
+ }
218
+ return run(parsed.data, known);
219
+ }
220
+ claim(auth, body) {
221
+ return this.#authed(auth, body, ClaimRequest, (request, device) => {
222
+ if (request.runnerId !== device.runnerId) {
223
+ return fail(401, "unauthorized", "runner id does not match the key");
224
+ }
225
+ const now = this.#deps.now();
226
+ this.#deps.state.sweep(now);
227
+ const kinds = new Set(request.capabilities.map((c) => c.kind));
228
+ const granted = [];
229
+ for (const job of this.#deps.state.jobs()) {
230
+ if (granted.length >= request.max) break;
231
+ if (job.state !== "queued") continue;
232
+ if (job.siteId !== this.#deps.siteId) continue;
233
+ if (!kinds.has(job.stub.kind)) continue;
234
+ if (!this.#deps.projection.mayRunFor(device.owner, job.stub.owner)) {
235
+ continue;
236
+ }
237
+ const leaseId = randomUUID();
238
+ job.state = "awaiting-payload";
239
+ job.claimedBy = {
240
+ runnerId: device.runnerId,
241
+ owner: device.owner,
242
+ device: device.device,
243
+ leaseId,
244
+ leaseExpiresAt: now + this.#deps.leaseMs
245
+ };
246
+ job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
247
+ granted.push({
248
+ ...job.stub,
249
+ lease: {
250
+ id: leaseId,
251
+ runnerId: device.runnerId,
252
+ expiresAt: job.claimedBy.leaseExpiresAt
253
+ }
254
+ });
255
+ }
256
+ return ok({ jobs: granted, leaseMs: this.#deps.leaseMs });
257
+ });
258
+ }
259
+ /**
260
+ * Hand over the sealed payload, if the site has left one.
261
+ *
262
+ * The one endpoint whose behaviour differs from a direct site's, and the
263
+ * difference is the whole design: a direct site seals here, on demand,
264
+ * because it holds the keys. The relay waits. A `409` means "claimed, not
265
+ * yet sealed" — a daemon should retry, not treat it as a refusal, because
266
+ * the job is still legitimately theirs until the lease or the
267
+ * awaiting-payload clock says otherwise.
268
+ */
269
+ fetch(auth, body) {
270
+ return this.#authed(auth, body, FetchRequest, (request, device) => {
271
+ const job = this.#deps.state.job(request.jobId);
272
+ if (!job) return fail(404, "not-found", "unknown job");
273
+ if (job.claimedBy?.runnerId !== device.runnerId) {
274
+ return fail(403, "unauthorized", "this runner does not hold the job");
275
+ }
276
+ if (job.claimedBy.leaseId !== request.leaseId) {
277
+ return fail(403, "unauthorized", "that lease is no longer current");
278
+ }
279
+ if (!job.payload) {
280
+ return fail(409, "not-ready", "the site has not sealed this job yet");
281
+ }
282
+ job.state = "running";
283
+ return ok({ envelope: job.payload });
284
+ });
285
+ }
286
+ /**
287
+ * Take a sealed result.
288
+ *
289
+ * The relay stores ciphertext and records the disposition so it can stop
290
+ * dispatching. It cannot check the two against each other — that requires
291
+ * opening the envelope, which is the site's job and the site's key. This is
292
+ * the asymmetry byollm_009 §6.1 describes: the hint is actionable here and
293
+ * only verifiable there.
294
+ */
295
+ result(auth, body) {
296
+ return this.#authed(auth, body, ResultRequest, (request, device) => {
297
+ const job = this.#deps.state.job(request.jobId);
298
+ if (!job) return fail(404, "not-found", "unknown job");
299
+ if (job.claimedBy?.runnerId !== device.runnerId) {
300
+ return fail(403, "unauthorized", "this runner does not hold the job");
301
+ }
302
+ if (job.state === "done") {
303
+ return ok({ accepted: false, state: job.state });
304
+ }
305
+ job.result = request.envelope;
306
+ job.disposition = request.disposition;
307
+ job.state = "done";
308
+ return ok({ accepted: true, state: job.state });
309
+ });
310
+ }
311
+ heartbeat(auth, body) {
312
+ return this.#authed(
313
+ auth,
314
+ body,
315
+ HeartbeatRequest,
316
+ (request, device) => {
317
+ const now = this.#deps.now();
318
+ this.#deps.state.sweep(now);
319
+ const known = this.#deps.state.presence(device.runnerId);
320
+ const consent = this.#deps.projection.consentFor(
321
+ device.owner,
322
+ this.#deps.siteId
323
+ );
324
+ const revoked = consent === null;
325
+ if (known) known.revoked = revoked;
326
+ const lost = request.activeLeases.filter(({ jobId, leaseId }) => {
327
+ const job = this.#deps.state.job(jobId);
328
+ return job?.claimedBy?.leaseId !== leaseId;
329
+ }).map(({ jobId }) => jobId);
330
+ return ok({
331
+ revoked,
332
+ cancel: [],
333
+ leases: [],
334
+ lost,
335
+ serverTime: now
336
+ });
337
+ },
338
+ { allowRevoked: true }
339
+ );
340
+ }
341
+ release(auth, body) {
342
+ return this.#authed(auth, body, ReleaseRequest, (request, device) => {
343
+ const released = [];
344
+ for (const { jobId, leaseId } of request.leases) {
345
+ const job = this.#deps.state.job(jobId);
346
+ if (!job || job.claimedBy?.runnerId !== device.runnerId) continue;
347
+ if (job.claimedBy.leaseId !== leaseId) continue;
348
+ this.#deps.state.requeue(job);
349
+ released.push(jobId);
350
+ }
351
+ return ok({ released });
352
+ });
353
+ }
354
+ };
355
+ var PairFixtureRequest = z.object({
356
+ protocolVersion: z.literal(PROTOCOL_VERSION),
357
+ owner: z.string().min(1),
358
+ device: PublicIdentity
359
+ }).strict();
360
+ var fingerprintOf = (identity) => keyId(identity.identity);
361
+
362
+ // src/debug.ts
363
+ var escape = (value) => value.replace(
364
+ /[&<>"]/g,
365
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] ?? c
366
+ );
367
+ var STATE_COLOUR = {
368
+ queued: "#8a8a8a",
369
+ "awaiting-payload": "#c98a00",
370
+ ready: "#0a7",
371
+ running: "#06c",
372
+ done: "#444"
373
+ };
374
+ function jobRow(job, now) {
375
+ const claimed = job.claimedBy;
376
+ const waiting = job.state === "awaiting-payload" && job.awaitingUntil !== void 0 ? `${String(Math.max(0, job.awaitingUntil - now))}ms left` : "";
377
+ return `<tr>
378
+ <td><code>${escape(job.id)}</code></td>
379
+ <td>${escape(job.stub.kind)}</td>
380
+ <td>${escape(job.stub.owner)}</td>
381
+ <td>${escape(job.stub.audience)}</td>
382
+ <td>${escape(job.stub.sizeClass)}</td>
383
+ <td>${job.stub.streaming ? "yes" : "no"}</td>
384
+ <td><b style="color:${STATE_COLOUR[job.state] ?? "#000"}">${escape(job.state)}</b> <span class="dim">${escape(waiting)}</span></td>
385
+ <td>${claimed ? `<code>${escape(fingerprintOf(claimed.device))}</code>` : "<span class='dim'>\u2014</span>"}</td>
386
+ <td>${job.payload ? "sealed" : "<span class='dim'>\u2014</span>"}</td>
387
+ <td>${job.result ? escape(job.disposition ?? "?") : "<span class='dim'>\u2014</span>"}</td>
388
+ </tr>`;
389
+ }
390
+ function debugPage(state, now) {
391
+ const jobs = state.jobs();
392
+ const devices = state.everyone();
393
+ return `<!doctype html>
394
+ <html><head><meta charset="utf-8"><title>byollm relay \u2014 debug</title>
395
+ <meta http-equiv="refresh" content="1">
396
+ <style>
397
+ body{font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;margin:24px;color:#111;background:#fff}
398
+ h1{font-size:15px;margin:0 0 4px} h2{font-size:13px;margin:24px 0 6px}
399
+ table{border-collapse:collapse;width:100%;margin-top:4px}
400
+ th,td{text-align:left;padding:4px 8px;border-bottom:1px solid #eee;vertical-align:top}
401
+ th{font-weight:600;color:#666;border-bottom:1px solid #ccc}
402
+ code{background:#f5f5f5;padding:1px 4px;border-radius:3px}
403
+ .dim{color:#aaa} .note{color:#666;max-width:70ch;margin:8px 0 0}
404
+ @media(prefers-color-scheme:dark){
405
+ body{background:#111;color:#eee} th{color:#999;border-color:#333}
406
+ td{border-color:#222} code{background:#1c1c1c} .note{color:#999}
407
+ }
408
+ </style></head><body>
409
+ <h1>byollm relay \u2014 debug</h1>
410
+ <p class="note">Everything this relay knows, which is everything on this page.
411
+ There is no prompt or result text here because it holds none: payloads and
412
+ results are sealed to their endpoints and pass through as ciphertext.</p>
413
+
414
+ <h2>Routed jobs (${String(jobs.length)})</h2>
415
+ <table>
416
+ <tr><th>job</th><th>kind</th><th>owner</th><th>audience</th><th>size</th>
417
+ <th>stream</th><th>state</th><th>claimed by</th><th>payload</th><th>result</th></tr>
418
+ ${jobs.length ? jobs.map((j) => jobRow(j, now)).join("\n") : `<tr><td colspan="10" class="dim">nothing routed yet</td></tr>`}
419
+ </table>
420
+
421
+ <h2>Presence (${String(devices.length)})</h2>
422
+ <table>
423
+ <tr><th>runner</th><th>owner</th><th>fingerprint</th><th>last seen</th><th>routing</th></tr>
424
+ ${devices.length ? devices.map(
425
+ (d) => `<tr>
426
+ <td><code>${escape(d.runnerId)}</code></td>
427
+ <td>${escape(d.owner)}</td>
428
+ <td><code>${escape(fingerprintOf(d.device))}</code></td>
429
+ <td>${String(Math.max(0, now - d.lastSeenAt))}ms ago</td>
430
+ <td>${d.revoked ? "<b style='color:#c00'>revoked</b>" : "active"}</td>
431
+ </tr>`
432
+ ).join("\n") : `<tr><td colspan="5" class="dim">no devices connected</td></tr>`}
433
+ </table>
434
+ </body></html>`;
435
+ }
436
+
437
+ // src/fixture.ts
438
+ import { PublicIdentity as PublicIdentity2 } from "@byollm/protocol";
439
+ import { z as z2 } from "zod";
440
+ var SiteRecord = z2.object({
441
+ /** How the control plane names the site. */
442
+ siteId: z2.string().min(1),
443
+ /**
444
+ * The site's public identity.
445
+ *
446
+ * The relay distributes it and cannot use it: an identity key verifies
447
+ * signatures and seals nothing. This is the key-exchange half of consent
448
+ * (cloud_004 §3), and both endpoints pin what they receive.
449
+ */
450
+ site: PublicIdentity2
451
+ }).strict();
452
+ var ConsentRecord = z2.object({
453
+ /** The user, as the control plane identifies them. */
454
+ owner: z2.string().min(1),
455
+ /** Which site this consent is for. Scoped: consent is never global. */
456
+ siteId: z2.string().min(1)
457
+ }).strict();
458
+ var RosterRecord = z2.object({
459
+ /** Stable id for the group, used only inside the relay. */
460
+ id: z2.string().min(1),
461
+ /** Who owns the shared compute. */
462
+ owner: z2.string().min(1),
463
+ /** Members who may route to it. The owner is not implicitly a member. */
464
+ members: z2.array(z2.string().min(1))
465
+ }).strict();
466
+ var DeviceRecord = z2.object({
467
+ /** Who approved it. */
468
+ owner: z2.string().min(1),
469
+ /** The id the control plane assigned — the device does not choose it. */
470
+ runnerId: z2.string().min(1),
471
+ /** The keys a human compared a fingerprint of before approving. */
472
+ device: PublicIdentity2
473
+ }).strict();
474
+ var RevocationRecord = z2.object({ owner: z2.string().min(1), siteId: z2.string().min(1) }).strict();
475
+ var RelayFixture = z2.object({
476
+ /** Registered sites, by id. A consent for a site absent here routes not. */
477
+ sites: z2.array(SiteRecord).default([]),
478
+ consents: z2.array(ConsentRecord),
479
+ devices: z2.array(DeviceRecord).default([]),
480
+ rosters: z2.array(RosterRecord).default([]),
481
+ /**
482
+ * Routes that were revoked, as structured pairs.
483
+ *
484
+ * A separate list rather than deleting the consent record, because the
485
+ * freeze gate needs revocation to be an observable *event* rather than an
486
+ * absence — "the row is gone" and "the row was revoked" are different
487
+ * answers to someone debugging why routing stopped.
488
+ *
489
+ * `{owner, siteId}` and never the composite string `"owner:siteId"`. A
490
+ * composite key is a parser waiting to meet an id containing its
491
+ * separator, which is the lesson the composite lease ids taught against
492
+ * Postgres — applied here before it became a contract.
493
+ */
494
+ revoked: z2.array(RevocationRecord).default([])
495
+ }).strict();
496
+ var EMPTY_FIXTURE = {
497
+ sites: [],
498
+ consents: [],
499
+ devices: [],
500
+ rosters: [],
501
+ revoked: []
502
+ };
503
+ var Projection = class {
504
+ #fixture;
505
+ constructor(fixture = EMPTY_FIXTURE) {
506
+ this.#fixture = RelayFixture.parse(fixture);
507
+ }
508
+ /** Replace the projection wholesale — the control plane pushed a new one. */
509
+ replace(fixture) {
510
+ this.#fixture = RelayFixture.parse(fixture);
511
+ }
512
+ /**
513
+ * The site this id names, if the control plane registered it.
514
+ *
515
+ * The only source of a site's public identity in this package. Everything
516
+ * that pins, verifies or seals to a site starts here.
517
+ */
518
+ siteFor(siteId) {
519
+ return this.#fixture.sites.find((s) => s.siteId === siteId) ?? null;
520
+ }
521
+ /**
522
+ * The device this runner id names, if a human approved it.
523
+ *
524
+ * Returns null for a device the control plane does not know, which is how
525
+ * the relay refuses to be the authority on identity.
526
+ */
527
+ deviceFor(runnerId) {
528
+ return this.#fixture.devices.find((d) => d.runnerId === runnerId) ?? null;
529
+ }
530
+ /** The device approved for these exact keys, if any. */
531
+ deviceByFingerprint(identityPublic) {
532
+ return this.#fixture.devices.find((d) => d.device.identity === identityPublic) ?? null;
533
+ }
534
+ /** The consent binding this owner to this site, if it exists and stands. */
535
+ consentFor(owner, siteId) {
536
+ const revoked = this.#fixture.revoked.some(
537
+ (r) => r.owner === owner && r.siteId === siteId
538
+ );
539
+ if (revoked) return null;
540
+ return this.#fixture.consents.find(
541
+ (c) => c.owner === owner && c.siteId === siteId
542
+ ) ?? null;
543
+ }
544
+ /**
545
+ * May this device's owner run work belonging to `jobOwner`?
546
+ *
547
+ * The relay's half of `AUDIENCE_BOTH_SIDES`. It is only ever a *narrowing*:
548
+ * the daemon re-checks its own allowlist locally and may still refuse, and
549
+ * the site's audience already bounded who could be offered the job. A relay
550
+ * that answered `true` for everyone would not widen anything — which is
551
+ * exactly the property that lets it be blind.
552
+ */
553
+ mayRunFor(deviceOwner, jobOwner) {
554
+ if (deviceOwner === jobOwner) return true;
555
+ return this.#fixture.rosters.some(
556
+ (r) => r.owner === deviceOwner && r.members.includes(jobOwner)
557
+ );
558
+ }
559
+ };
560
+
561
+ // src/site-plane.ts
562
+ import {
563
+ JobStub,
564
+ RequestSignature as RequestSignature2,
565
+ SealedEnvelope,
566
+ verifySiteRequest
567
+ } from "@byollm/protocol";
568
+ import { z as z3 } from "zod";
569
+ var EnqueueRequest = z3.object({
570
+ siteId: z3.string().min(1),
571
+ /**
572
+ * Everything the relay learns about the job.
573
+ *
574
+ * `JobStub` is exhaustive by construction and asserted so in the protocol
575
+ * package — a site that tried to attach a prompt here would be refused by
576
+ * the schema, not by a reviewer.
577
+ */
578
+ stub: JobStub
579
+ }).strict();
580
+ var PayloadRequest = z3.object({
581
+ siteId: z3.string().min(1),
582
+ jobId: z3.string().min(1),
583
+ /** Sealed to the claiming device. Opaque to us and to the schema. */
584
+ envelope: SealedEnvelope
585
+ }).strict();
586
+ var QueryRequest = z3.object({ siteId: z3.string().min(1) }).strict();
587
+ var ok2 = (body) => ({ status: 200, body });
588
+ var fail2 = (status, error, message) => ({
589
+ status,
590
+ body: { error, message }
591
+ });
592
+ var SitePlane = class {
593
+ #deps;
594
+ constructor(deps) {
595
+ this.#deps = deps;
596
+ }
597
+ /**
598
+ * Signature first, then the site id, then the work.
599
+ *
600
+ * The caller is whoever the signature says, verified against the key the
601
+ * control plane registered — never whoever the request claims. The `siteId`
602
+ * every request carries is then required to *match* that caller, so the two
603
+ * can never name different sites; a request that says one thing in its
604
+ * signed material and another in its body is refused rather than reconciled.
605
+ *
606
+ * Every endpoint goes through here, including the reads. That is deliberate:
607
+ * an authenticated write plane beside an open read plane would still hand a
608
+ * stranger presence, claims and lease ids, and "who is online right now" is
609
+ * exactly the fact a blind relay is otherwise so careful not to reveal.
610
+ */
611
+ #authed(auth, body, schema, siteIdOf, run) {
612
+ const signature = RequestSignature2.safeParse(auth.signature);
613
+ if (!signature.success) {
614
+ return fail2(401, "unauthorized", "this request is not signed");
615
+ }
616
+ const siteId = signature.data.runnerId;
617
+ const site = this.#deps.projection.siteFor(siteId);
618
+ if (!site) {
619
+ return fail2(401, "unauthorized", "this site is not registered");
620
+ }
621
+ const failure = verifySiteRequest({
622
+ identityPublic: site.site.identity,
623
+ endpoint: auth.endpoint,
624
+ body: auth.rawBody,
625
+ signature: signature.data,
626
+ now: this.#deps.now()
627
+ });
628
+ if (failure) return fail2(401, "unauthorized", "signature check failed");
629
+ const parsed = schema.safeParse(body);
630
+ if (!parsed.success || parsed.data === void 0) {
631
+ return fail2(400, "bad-request", "request failed schema validation");
632
+ }
633
+ if (siteIdOf(parsed.data) !== siteId) {
634
+ return fail2(403, "unauthorized", "that is not your site");
635
+ }
636
+ if (siteId !== this.#deps.routesFor) {
637
+ return fail2(403, "unauthorized", "this relay does not route for you");
638
+ }
639
+ return run(parsed.data, siteId);
640
+ }
641
+ enqueue(auth, body) {
642
+ return this.#authed(
643
+ auth,
644
+ body,
645
+ EnqueueRequest,
646
+ (request) => request.siteId,
647
+ (request, siteId) => {
648
+ const job = this.#deps.state.enqueue({
649
+ id: request.stub.id,
650
+ siteId,
651
+ stub: request.stub
652
+ });
653
+ return ok2({ jobId: job.id, state: job.state });
654
+ }
655
+ );
656
+ }
657
+ /**
658
+ * What needs sealing, and who to seal it to.
659
+ *
660
+ * The response carries the claiming device's **public** keys — which is the
661
+ * entire reason a blind relay can exist. The relay is a directory here, not
662
+ * a participant: it tells the site an address, and what the site sends to
663
+ * that address is unreadable on the way through.
664
+ */
665
+ pending(auth, siteId) {
666
+ return this.#authed(
667
+ auth,
668
+ { siteId },
669
+ QueryRequest,
670
+ (request) => request.siteId,
671
+ (_request, site) => {
672
+ this.#deps.state.sweep(this.#deps.now());
673
+ const jobs = this.#deps.state.awaiting(site).map((job) => ({
674
+ jobId: job.id,
675
+ // Non-null by construction: `awaiting` only returns claimed jobs.
676
+ // The optional chain is here so a future state-machine edit that
677
+ // broke that invariant would produce a missing field rather than a
678
+ // crash on the routing path.
679
+ device: job.claimedBy?.device,
680
+ runnerId: job.claimedBy?.runnerId,
681
+ leaseId: job.claimedBy?.leaseId,
682
+ /** So a site can decline to seal for a claim about to expire. */
683
+ awaitingUntil: job.awaitingUntil
684
+ }));
685
+ return ok2({ jobs });
686
+ }
687
+ );
688
+ }
689
+ payload(auth, body) {
690
+ return this.#authed(
691
+ auth,
692
+ body,
693
+ PayloadRequest,
694
+ (request) => request.siteId,
695
+ (request, siteId) => {
696
+ const job = this.#deps.state.job(request.jobId);
697
+ if (job?.siteId !== siteId) {
698
+ return fail2(404, "not-found", "unknown job");
699
+ }
700
+ if (job.state !== "awaiting-payload") {
701
+ return fail2(
702
+ 409,
703
+ "too-late",
704
+ `job is ${job.state}, not awaiting payload`
705
+ );
706
+ }
707
+ job.payload = request.envelope;
708
+ job.state = "ready";
709
+ delete job.awaitingUntil;
710
+ return ok2({ jobId: job.id, state: job.state });
711
+ }
712
+ );
713
+ }
714
+ /** Sealed results, for the site to open and verify. */
715
+ results(auth, siteId) {
716
+ return this.#authed(
717
+ auth,
718
+ { siteId },
719
+ QueryRequest,
720
+ (request) => request.siteId,
721
+ (_request, site) => {
722
+ const jobs = this.#deps.state.finished(site).map((job) => ({
723
+ jobId: job.id,
724
+ envelope: job.result,
725
+ disposition: job.disposition,
726
+ runnerId: job.claimedBy?.runnerId,
727
+ /** The grant the site adopted, so it can complete against it. */
728
+ leaseId: job.claimedBy?.leaseId,
729
+ /**
730
+ * Which device ran it, so the site can verify the signature against
731
+ * the key it was told to seal to — and so `RESULT_PROVENANCE` can
732
+ * name a foreign device rather than guessing (cloud_004 §11.2).
733
+ */
734
+ device: job.claimedBy?.device
735
+ }));
736
+ return ok2({ jobs });
737
+ }
738
+ );
739
+ }
740
+ };
741
+
742
+ // src/index.ts
743
+ var Relay = class {
744
+ state;
745
+ projection;
746
+ #daemon;
747
+ #site;
748
+ #now;
749
+ #basePath;
750
+ constructor(options) {
751
+ this.state = new RelayState();
752
+ this.projection = new Projection(options.fixture);
753
+ this.#now = options.now ?? Date.now;
754
+ this.#basePath = (options.basePath ?? "/byollm").replace(/\/+$/, "");
755
+ this.#daemon = new DaemonPlane({
756
+ state: this.state,
757
+ projection: this.projection,
758
+ now: this.#now,
759
+ leaseMs: options.leaseMs ?? 6e4,
760
+ siteId: options.siteId
761
+ });
762
+ this.#site = new SitePlane({
763
+ state: this.state,
764
+ projection: this.projection,
765
+ now: this.#now,
766
+ routesFor: options.siteId
767
+ });
768
+ }
769
+ /** Replace the projection — a control-plane push, or a fixture edit. */
770
+ project(fixture) {
771
+ this.projection.replace(fixture);
772
+ }
773
+ /**
774
+ * Fire due timers and report what moved.
775
+ *
776
+ * Exposed rather than run on an interval so a test can drive it, and so the
777
+ * production hub can decide its own scheduling. The relay never needs a
778
+ * timer to be *correct* — every read path sweeps first — but a job whose
779
+ * site vanished should return to the queue without waiting for someone to
780
+ * ask about it.
781
+ */
782
+ sweep() {
783
+ return { requeued: this.state.sweep(this.#now()).map((j) => j.id) };
784
+ }
785
+ /** The whole HTTP surface. */
786
+ async handle(request) {
787
+ const url = new URL(request.url);
788
+ const path = url.pathname;
789
+ if (path === "/debug" || path === `${this.#basePath}/debug`) {
790
+ return new Response(debugPage(this.state, this.#now()), {
791
+ headers: { "content-type": "text/html; charset=utf-8" }
792
+ });
793
+ }
794
+ const rawBody = request.method === "POST" ? await request.text() : "";
795
+ const body = rawBody === "" ? void 0 : safeJson(rawBody);
796
+ const endpoint = path.slice(path.lastIndexOf("/") + 1);
797
+ const auth = {
798
+ endpoint,
799
+ rawBody,
800
+ signature: signatureFrom(request.headers, "x-byollm-runner")
801
+ };
802
+ const siteAuth = {
803
+ endpoint,
804
+ rawBody,
805
+ signature: signatureFrom(request.headers, "x-byollm-site")
806
+ };
807
+ if (path === "/relay/site/enqueue") {
808
+ return json(this.#site.enqueue(siteAuth, body));
809
+ }
810
+ if (path === "/relay/site/payload") {
811
+ return json(this.#site.payload(siteAuth, body));
812
+ }
813
+ if (path === "/relay/site/pending") {
814
+ return json(
815
+ this.#site.pending(siteAuth, url.searchParams.get("siteId") ?? "")
816
+ );
817
+ }
818
+ if (path === "/relay/site/results") {
819
+ return json(
820
+ this.#site.results(siteAuth, url.searchParams.get("siteId") ?? "")
821
+ );
822
+ }
823
+ if (!path.startsWith(`${this.#basePath}/`)) {
824
+ return json({ status: 404, body: { error: "not-found" } });
825
+ }
826
+ switch (auth.endpoint) {
827
+ case "pair":
828
+ return json(this.#daemon.pair(body));
829
+ case "claim":
830
+ return json(this.#daemon.claim(auth, body));
831
+ case "fetch":
832
+ return json(this.#daemon.fetch(auth, body));
833
+ case "result":
834
+ return json(this.#daemon.result(auth, body));
835
+ case "heartbeat":
836
+ return json(this.#daemon.heartbeat(auth, body));
837
+ case "release":
838
+ return json(this.#daemon.release(auth, body));
839
+ default:
840
+ return json({ status: 404, body: { error: "not-found" } });
841
+ }
842
+ }
843
+ };
844
+ function safeJson(raw) {
845
+ try {
846
+ return JSON.parse(raw);
847
+ } catch {
848
+ return void 0;
849
+ }
850
+ }
851
+ function signatureFrom(headers, callerHeader) {
852
+ const runnerId = headers.get(callerHeader);
853
+ const issuedAt = headers.get("x-byollm-issued-at");
854
+ const signature = headers.get("x-byollm-signature");
855
+ if (runnerId === null || issuedAt === null || signature === null) {
856
+ return void 0;
857
+ }
858
+ return { runnerId, issuedAt: Number(issuedAt), signature };
859
+ }
860
+ var json = (result) => new Response(JSON.stringify(result.body), {
861
+ status: result.status,
862
+ headers: { "content-type": "application/json" }
863
+ });
864
+ export {
865
+ AWAITING_PAYLOAD_MS,
866
+ ConsentRecord,
867
+ DeviceRecord,
868
+ EMPTY_FIXTURE,
869
+ Projection,
870
+ Relay,
871
+ RelayFixture as RelayFixtureSchema,
872
+ RelayState,
873
+ RevocationRecord,
874
+ RosterRecord,
875
+ SiteRecord,
876
+ debugPage
877
+ };
878
+ //# sourceMappingURL=index.js.map