@byollm/relay 0.1.0-alpha.5 → 0.1.0-alpha.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,17 @@
1
+ import {
2
+ AWAITING_PAYLOAD_MS,
3
+ RelayState,
4
+ routeKey,
5
+ serviceKey
6
+ } from "./chunk-XLWNHHLS.js";
7
+
8
+ // src/index.ts
9
+ import { checkProtocolVersion, declaredVersion } from "@byollm/protocol";
10
+
1
11
  // src/daemon-plane.ts
2
12
  import {
13
+ PairPollRequest,
14
+ PairStartRequest,
3
15
  ClaimRequest,
4
16
  FetchRequest,
5
17
  HeartbeatRequest,
@@ -10,100 +22,124 @@ import {
10
22
  keyId,
11
23
  verifyRequest,
12
24
  verifyPublicIdentity,
13
- PublicIdentity
25
+ PublicIdentity,
26
+ ERROR_STATUS as ERROR_STATUS2
14
27
  } from "@byollm/protocol";
15
- import { randomUUID } from "crypto";
16
28
  import { z } from "zod";
17
29
 
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
- /** Take a stub for routing. The payload is not here and will not be. */
24
- enqueue(input) {
25
- const job = {
26
- id: input.id,
27
- siteId: input.siteId,
28
- stub: input.stub,
29
- state: "queued"
30
- };
31
- this.#jobs.set(job.id, job);
32
- return job;
33
- }
34
- job(jobId) {
35
- return this.#jobs.get(jobId);
36
- }
37
- jobs() {
38
- return [...this.#jobs.values()];
39
- }
40
- /** Jobs a site must seal for, right now. */
41
- awaiting(siteId) {
42
- return this.jobs().filter(
43
- (j) => j.siteId === siteId && j.state === "awaiting-payload"
44
- );
30
+ // src/pairing-codes.ts
31
+ import { randomBytes } from "crypto";
32
+ var PAIRING_BUSY_MESSAGE = "too many pairings are in progress right now \u2014 try again in a few minutes";
33
+ var HUMAN_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
34
+ function newUserCode() {
35
+ const groups = Array.from(
36
+ randomBytes(8),
37
+ (byte) => HUMAN_ALPHABET.charAt(byte % HUMAN_ALPHABET.length)
38
+ );
39
+ return `${groups.slice(0, 4).join("")}-${groups.slice(4).join("")}`;
40
+ }
41
+ var newDeviceCode = () => randomBytes(32).toString("base64url");
42
+ var PAIRING_CODE_TTL_MS = 10 * 60 * 1e3;
43
+ var MAX_OUTSTANDING_PAIRINGS = 500;
44
+ var MemoryPairingCodes = class {
45
+ #byDevice = /* @__PURE__ */ new Map();
46
+ #now;
47
+ #capacity;
48
+ constructor(now = Date.now, capacity = MAX_OUTSTANDING_PAIRINGS) {
49
+ this.#now = now;
50
+ this.#capacity = capacity;
45
51
  }
46
- /** Sealed results waiting to go home. */
47
- finished(siteId) {
48
- return this.jobs().filter(
49
- (j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
50
- );
52
+ #live(pending) {
53
+ if (!pending) return void 0;
54
+ return pending.expiresAt > this.#now() ? pending : void 0;
51
55
  }
52
- seen(presence) {
53
- const existing = this.#presence.get(presence.runnerId);
54
- if (existing) {
55
- existing.lastSeenAt = presence.lastSeenAt;
56
- return existing;
56
+ put(pending) {
57
+ const now = this.#now();
58
+ for (const [code, held] of this.#byDevice) {
59
+ if (held.expiresAt <= now) this.#byDevice.delete(code);
57
60
  }
58
- const fresh = { ...presence, revoked: false };
59
- this.#presence.set(presence.runnerId, fresh);
60
- return fresh;
61
- }
62
- presence(runnerId) {
63
- return this.#presence.get(runnerId);
64
- }
65
- everyone() {
66
- return [...this.#presence.values()];
61
+ const fingerprint = pending.device.identity;
62
+ for (const [code, held] of this.#byDevice) {
63
+ if (held.device.identity === fingerprint) this.#byDevice.delete(code);
64
+ }
65
+ if (this.#byDevice.size >= this.#capacity)
66
+ return Promise.resolve("at-capacity");
67
+ this.#byDevice.set(pending.deviceCode, pending);
68
+ return Promise.resolve("stored");
67
69
  }
68
- /**
69
- * Return a job to the queue, forgetting the claim.
70
- *
71
- * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
72
- * why the awaiting-payload timeout is cheap to fire: the worst case is that
73
- * a device did nothing for ten seconds and another one gets a turn.
74
- */
75
- requeue(job) {
76
- job.state = "queued";
77
- delete job.claimedBy;
78
- delete job.awaitingUntil;
79
- delete job.payload;
70
+ byDeviceCode(deviceCode) {
71
+ return Promise.resolve(this.#live(this.#byDevice.get(deviceCode)));
80
72
  }
81
- /**
82
- * Fire whatever the clock says is due, and report it.
83
- *
84
- * Returns the jobs it requeued so a caller can log or surface them — a
85
- * timeout that fires invisibly is indistinguishable from a job that was
86
- * never claimed, and those want very different debugging.
87
- */
88
- sweep(now) {
89
- const requeued = [];
90
- for (const job of this.#jobs.values()) {
91
- if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
92
- this.requeue(job);
93
- requeued.push(job);
94
- }
95
- const lease = job.claimedBy;
96
- if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
97
- this.requeue(job);
98
- requeued.push(job);
99
- }
73
+ byUserCode(userCode) {
74
+ const wanted = userCode.trim().toUpperCase();
75
+ for (const pending of this.#byDevice.values()) {
76
+ if (pending.userCode === wanted)
77
+ return Promise.resolve(this.#live(pending));
100
78
  }
101
- return requeued;
79
+ return Promise.resolve(void 0);
80
+ }
81
+ drop(deviceCode) {
82
+ this.#byDevice.delete(deviceCode);
83
+ return Promise.resolve();
102
84
  }
103
85
  };
104
86
 
87
+ // src/refusals.ts
88
+ import { ERROR_STATUS, MAX_CLOCK_SKEW_MS } from "@byollm/protocol";
89
+ function clockSkewRefusal(now) {
90
+ return {
91
+ status: ERROR_STATUS["clock-skew"],
92
+ body: {
93
+ error: "clock-skew",
94
+ message: "this request's timestamp is too far from the server's clock; check the machine's time and try again",
95
+ // So the far side can say *how far off* rather than *that something is
96
+ // wrong*. Not a disclosure: the heartbeat response returns the same
97
+ // value, and so does every `Date` header.
98
+ serverTime: now,
99
+ maxSkewMs: MAX_CLOCK_SKEW_MS
100
+ }
101
+ };
102
+ }
103
+
105
104
  // src/daemon-plane.ts
106
105
  var ok = (body) => ({ status: 200, body });
106
+ var REFUSALS = {
107
+ "not-found": {
108
+ status: 404,
109
+ body: { error: "not-found", message: "unknown job" }
110
+ },
111
+ // `forbidden`, not `unauthorized` — V1-13. Both of these are an
112
+ // *identified* caller being refused, which is what 403 means and what the
113
+ // table says `forbidden` is for; `unauthorized` is 401 and means "we do not
114
+ // know who you are". Served as 403 with a 401's code, a revoked daemon and
115
+ // an unsigned one looked alike in every log and every client branch, and
116
+ // "check your keys" is the wrong advice for both in opposite directions.
117
+ "not-holder": {
118
+ status: 403,
119
+ body: {
120
+ error: "forbidden",
121
+ message: "this runner does not hold the job"
122
+ }
123
+ },
124
+ "stale-lease": {
125
+ status: 403,
126
+ body: { error: "forbidden", message: "that lease is no longer current" }
127
+ },
128
+ "not-ready": {
129
+ status: 409,
130
+ body: {
131
+ error: "not-ready",
132
+ message: "the site has not sealed this job yet"
133
+ }
134
+ },
135
+ // Also a 409, and deliberately a different code: `not-ready` means keep
136
+ // asking and this means stop. A daemon that read them as one would poll a
137
+ // finished job until its lease ran out.
138
+ terminal: {
139
+ status: 409,
140
+ body: { error: "too-late", message: "this job has already finished" }
141
+ }
142
+ };
107
143
  var fail = (status, error, message) => ({
108
144
  status,
109
145
  body: { error, message }
@@ -123,7 +159,11 @@ var DaemonPlane = class {
123
159
  * relay that substituted its own identity here could inject work — and would
124
160
  * need a private key to do it, which is why it has none.
125
161
  */
126
- pair(body) {
162
+ async pair(body) {
163
+ const start = PairStartRequest.safeParse(body);
164
+ if (start.success) return this.#pairStart(start.data);
165
+ const poll = PairPollRequest.safeParse(body);
166
+ if (poll.success) return this.#pairPoll(poll.data);
127
167
  const parsed = PairFixtureRequest.safeParse(body);
128
168
  if (!parsed.success) {
129
169
  return fail(400, "bad-request", "pair request failed schema validation");
@@ -131,36 +171,167 @@ var DaemonPlane = class {
131
171
  if (!verifyPublicIdentity(parsed.data.device)) {
132
172
  return fail(400, "bad-request", "the device identity is not consistent");
133
173
  }
134
- const consent = this.#deps.projection.consentFor(
135
- parsed.data.owner,
136
- this.#deps.siteId
174
+ const sites = this.#deps.projection.sitesFor(parsed.data.owner);
175
+ if (sites.length === 0) {
176
+ return fail(403, "forbidden", "no consent record for this user");
177
+ }
178
+ const approved = this.#deps.projection.deviceByFingerprint(
179
+ parsed.data.device.identity
137
180
  );
138
- if (!consent) {
139
- return fail(403, "unauthorized", "no consent record for this user");
181
+ if (!approved) {
182
+ return fail(
183
+ 403,
184
+ "forbidden",
185
+ "this device has not been approved by its owner"
186
+ );
140
187
  }
141
- const runnerId = randomUUID();
142
- this.#deps.state.seen({
188
+ if (approved.owner !== parsed.data.owner) {
189
+ return fail(403, "forbidden", "this device belongs to another owner");
190
+ }
191
+ const runnerId = approved.runnerId;
192
+ await this.#deps.state.seen({
143
193
  runnerId,
144
194
  owner: parsed.data.owner,
145
195
  device: parsed.data.device,
146
- lastSeenAt: this.#deps.now()
196
+ // The fixture exchange carries no matrix — it models a consent that
197
+ // arrived from a file, not a daemon describing itself. Empty until the
198
+ // first heartbeat, which is seconds away and is the authority anyway.
199
+ capabilities: [],
200
+ withheld: []
147
201
  });
148
202
  return ok({
149
203
  protocolVersion: PROTOCOL_VERSION,
150
204
  runnerId,
151
- /** The *site's* key. See the note above — this is load-bearing. */
152
- site: consent.site
205
+ /**
206
+ * The *sites'* keys. See the note above — this is load-bearing.
207
+ *
208
+ * The set this owner has consented to, keyed by each site's identity
209
+ * key id (cloud_009 §5). Paused sites are here: a paused consent keeps
210
+ * its pin so re-consenting never costs a re-pair, and what it does not
211
+ * do is route.
212
+ */
213
+ sites: Object.fromEntries(
214
+ sites.map((record) => [keyId(record.site.identity), record.site])
215
+ )
216
+ });
217
+ }
218
+ /**
219
+ * Mint a code, remember the keys it stands for, and send the human away.
220
+ *
221
+ * Nothing is decided here. The relay holds an assertion — "this keypair
222
+ * would like to be a machine" — for ten minutes, and the decision happens
223
+ * where the person is signed in.
224
+ */
225
+ async #pairStart(request) {
226
+ const codes = this.#deps.pairingCodes;
227
+ const verificationUrl = this.#deps.verificationUrl;
228
+ if (!codes || verificationUrl === void 0) {
229
+ return fail(
230
+ 501,
231
+ "bad-request",
232
+ "this relay does not offer device-code pairing"
233
+ );
234
+ }
235
+ if (!verifyPublicIdentity(request.device)) {
236
+ return fail(400, "bad-request", "the device identity is not consistent");
237
+ }
238
+ const pending = {
239
+ deviceCode: newDeviceCode(),
240
+ userCode: newUserCode(),
241
+ device: request.device,
242
+ label: request.daemon.label,
243
+ platform: request.daemon.platform,
244
+ capabilities: request.capabilities,
245
+ expiresAt: this.#deps.now() + PAIRING_CODE_TTL_MS
246
+ };
247
+ if (await codes.put(pending) === "at-capacity") {
248
+ return fail(
249
+ ERROR_STATUS2["rate-limited"],
250
+ "rate-limited",
251
+ PAIRING_BUSY_MESSAGE
252
+ );
253
+ }
254
+ return ok({
255
+ deviceCode: pending.deviceCode,
256
+ userCode: pending.userCode,
257
+ verificationUrl,
258
+ expiresAt: pending.expiresAt,
259
+ // Two seconds: fast enough that approving feels immediate, slow enough
260
+ // that a forgotten terminal is not a load generator.
261
+ pollIntervalMs: 2e3
262
+ });
263
+ }
264
+ /**
265
+ * Has anybody approved this keypair yet?
266
+ *
267
+ * The answer comes from the **projection** — the control plane's own record
268
+ * of devices a human approved — and never from a flag set here. That is the
269
+ * whole shape of the fence: the dashboard writes the approval to its own
270
+ * database, the hub's projection catches up within a poll, and this notices.
271
+ * No write crosses in either direction.
272
+ */
273
+ async #pairPoll(request) {
274
+ const codes = this.#deps.pairingCodes;
275
+ if (!codes) {
276
+ return fail(
277
+ 501,
278
+ "bad-request",
279
+ "this relay does not offer device-code pairing"
280
+ );
281
+ }
282
+ const pending = await codes.byDeviceCode(request.deviceCode);
283
+ if (!pending) return ok({ status: "expired" });
284
+ const approved = this.#deps.projection.deviceByFingerprint(
285
+ pending.device.identity
286
+ );
287
+ if (!approved) return ok({ status: "pending" });
288
+ const sites = this.#deps.projection.sitesFor(approved.owner);
289
+ await this.#deps.state.seen({
290
+ runnerId: approved.runnerId,
291
+ owner: approved.owner,
292
+ device: pending.device,
293
+ // What this machine said it could run when it asked to pair, so the
294
+ // approval screen and the machines page have an answer in the same
295
+ // moment the device appears. The next heartbeat replaces it.
296
+ capabilities: pending.capabilities,
297
+ // A pairing request describes what a machine *can* run, not what it is
298
+ // holding back — the daemon resolves defaults locally, and the first
299
+ // heartbeat is where that answer arrives.
300
+ withheld: []
301
+ });
302
+ await codes.drop(pending.deviceCode);
303
+ return ok({
304
+ status: "approved",
305
+ runnerId: approved.runnerId,
306
+ owner: approved.owner,
307
+ sites: Object.fromEntries(
308
+ sites.map((record) => [keyId(record.site.identity), record.site])
309
+ )
153
310
  });
154
311
  }
155
312
  /** Every authenticated call: signature first, then consent, then work. */
156
- #authed(input, body, schema, run, options = {}) {
313
+ async #authed(input, body, schema, run, options = {}) {
157
314
  const signature = RequestSignature.safeParse(input.signature);
158
315
  if (!signature.success) {
159
316
  return fail(401, "unauthorized", "this request is not signed");
160
317
  }
161
- const known = this.#deps.state.presence(signature.data.runnerId);
318
+ let known = await this.#deps.state.presence(signature.data.runnerId);
319
+ let rebuilt = false;
162
320
  if (!known) {
163
- return fail(401, "unauthorized", "this runner is not recognised");
321
+ const approved = this.#deps.projection.deviceFor(signature.data.runnerId);
322
+ if (!approved) {
323
+ return fail(401, "unauthorized", "this runner is not recognised");
324
+ }
325
+ known = {
326
+ ...approved,
327
+ lastSeenAt: this.#deps.now(),
328
+ // Not invented. The heartbeat is the authority on what a machine can
329
+ // run, and it is seconds away; claiming a matrix here would be this
330
+ // file guessing about a backend it cannot see.
331
+ capabilities: [],
332
+ withheld: []
333
+ };
334
+ rebuilt = true;
164
335
  }
165
336
  const failure = verifyRequest({
166
337
  identityPublic: known.device.identity,
@@ -169,8 +340,21 @@ var DaemonPlane = class {
169
340
  signature: signature.data,
170
341
  now: this.#deps.now()
171
342
  });
343
+ if (failure === "stale") return this.#clockSkew();
172
344
  if (failure) return fail(401, "unauthorized", "signature check failed");
173
- if (known.revoked && options.allowRevoked !== true) {
345
+ if (rebuilt) {
346
+ await this.#deps.state.seen({
347
+ runnerId: known.runnerId,
348
+ owner: known.owner,
349
+ device: known.device,
350
+ capabilities: [],
351
+ // Nothing has described itself yet, so nothing is withheld — the
352
+ // first heartbeat is the authority on both.
353
+ withheld: []
354
+ });
355
+ }
356
+ const revoked = this.#deps.projection.revokedOutright(known.owner);
357
+ if (revoked && options.allowRevoked !== true) {
174
358
  return fail(403, "revoked", "routing for this runner has been revoked");
175
359
  }
176
360
  known.lastSeenAt = this.#deps.now();
@@ -180,41 +364,57 @@ var DaemonPlane = class {
180
364
  }
181
365
  return run(parsed.data, known);
182
366
  }
367
+ /**
368
+ * A clock too far from ours, said plainly and with the number to fix it by.
369
+ *
370
+ * Its own error code rather than a generic `unauthorized`, because it is the
371
+ * one refusal a retry can never fix and an `ntpdate` always can — the same
372
+ * reasoning `version-unsupported` already carries on the daemon side. A
373
+ * daemon that reports this as a generic rejection sends its owner looking at
374
+ * their network.
375
+ *
376
+ * `serverTime` is included so the far side can say *how far off* rather than
377
+ * *that something is wrong*. It is not a disclosure: the heartbeat response
378
+ * returns the same value, and so does every `Date` header.
379
+ */
380
+ #clockSkew() {
381
+ return clockSkewRefusal(this.#deps.now());
382
+ }
183
383
  claim(auth, body) {
184
- return this.#authed(auth, body, ClaimRequest, (request, device) => {
384
+ return this.#authed(auth, body, ClaimRequest, async (request, device) => {
185
385
  if (request.runnerId !== device.runnerId) {
186
- return fail(401, "unauthorized", "runner id does not match the key");
187
- }
188
- const now = this.#deps.now();
189
- this.#deps.state.sweep(now);
190
- const kinds = new Set(request.capabilities.map((c) => c.kind));
191
- const granted = [];
192
- for (const job of this.#deps.state.jobs()) {
193
- if (granted.length >= request.max) break;
194
- if (job.state !== "queued") continue;
195
- if (!kinds.has(job.stub.kind)) continue;
196
- if (!this.#deps.projection.mayRunFor(device.owner, job.stub.owner)) {
197
- continue;
198
- }
199
- const leaseId = randomUUID();
200
- job.state = "awaiting-payload";
201
- job.claimedBy = {
202
- runnerId: device.runnerId,
203
- owner: device.owner,
204
- device: device.device,
205
- leaseId,
206
- leaseExpiresAt: now + this.#deps.leaseMs
207
- };
208
- job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
209
- granted.push({
210
- ...job.stub,
211
- lease: {
212
- id: leaseId,
213
- runnerId: device.runnerId,
214
- expiresAt: job.claimedBy.leaseExpiresAt
215
- }
216
- });
386
+ return fail(403, "forbidden", "runner id does not match the key");
217
387
  }
388
+ const granted = await this.#deps.state.claim({
389
+ runnerId: device.runnerId,
390
+ owner: device.owner,
391
+ device: device.device,
392
+ // Only the defaults. byollm_016 Phase B advertises every selectable
393
+ // service per kind — the menu — so a device may send several rows for
394
+ // one kind, and exactly one of them is the one an *unselected* job
395
+ // should reach. Taking every row here would put an unselected job on
396
+ // whichever service happened to sort first, which is the guess the
397
+ // whole withheld mechanism exists to refuse.
398
+ kinds: new Set(
399
+ request.capabilities.filter((c) => c.isDefault).map((c) => c.kind)
400
+ ),
401
+ // The whole menu, as pairs. A job that named a service reaches only a
402
+ // device advertising that exact (kind, service) — never a fallback.
403
+ serves: new Set(
404
+ request.capabilities.map((c) => serviceKey(c.kind, c.service))
405
+ ),
406
+ // The projection, collapsed to data the store can match on — a
407
+ // predicate does not travel, and a set of (site, owner) pairs is
408
+ // what a route is (cloud_009 §3).
409
+ //
410
+ // A paused consent (cloud_008 finding 48) drops its routes and
411
+ // leaves the rest, which is the whole difference between "we are
412
+ // waiting for you to read something about one site" and "a human cut
413
+ // you off from everything".
414
+ routes: this.#deps.projection.routesFor(device.owner),
415
+ max: request.max,
416
+ leaseMs: this.#deps.leaseMs
417
+ });
218
418
  return ok({ jobs: granted, leaseMs: this.#deps.leaseMs });
219
419
  });
220
420
  }
@@ -229,20 +429,14 @@ var DaemonPlane = class {
229
429
  * awaiting-payload clock says otherwise.
230
430
  */
231
431
  fetch(auth, body) {
232
- return this.#authed(auth, body, FetchRequest, (request, device) => {
233
- const job = this.#deps.state.job(request.jobId);
234
- if (!job) return fail(404, "not-found", "unknown job");
235
- if (job.claimedBy?.runnerId !== device.runnerId) {
236
- return fail(403, "unauthorized", "this runner does not hold the job");
237
- }
238
- if (job.claimedBy.leaseId !== request.leaseId) {
239
- return fail(403, "unauthorized", "that lease is no longer current");
240
- }
241
- if (!job.payload) {
242
- return fail(409, "not-ready", "the site has not sealed this job yet");
243
- }
244
- job.state = "running";
245
- return ok({ envelope: job.payload });
432
+ return this.#authed(auth, body, FetchRequest, async (request, device) => {
433
+ const taken = await this.#deps.state.takePayload({
434
+ jobId: request.jobId,
435
+ runnerId: device.runnerId,
436
+ leaseId: request.leaseId
437
+ });
438
+ if ("refused" in taken) return REFUSALS[taken.refused];
439
+ return ok({ envelope: taken.envelope });
246
440
  });
247
441
  }
248
442
  /**
@@ -255,19 +449,16 @@ var DaemonPlane = class {
255
449
  * only verifiable there.
256
450
  */
257
451
  result(auth, body) {
258
- return this.#authed(auth, body, ResultRequest, (request, device) => {
259
- const job = this.#deps.state.job(request.jobId);
260
- if (!job) return fail(404, "not-found", "unknown job");
261
- if (job.claimedBy?.runnerId !== device.runnerId) {
262
- return fail(403, "unauthorized", "this runner does not hold the job");
263
- }
264
- if (job.state === "done") {
265
- return ok({ accepted: false, state: job.state });
266
- }
267
- job.result = request.envelope;
268
- job.disposition = request.disposition;
269
- job.state = "done";
270
- return ok({ accepted: true, state: job.state });
452
+ return this.#authed(auth, body, ResultRequest, async (request, device) => {
453
+ const recorded = await this.#deps.state.complete({
454
+ jobId: request.jobId,
455
+ runnerId: device.runnerId,
456
+ leaseId: request.leaseId,
457
+ envelope: request.envelope,
458
+ disposition: request.disposition
459
+ });
460
+ if ("refused" in recorded) return REFUSALS[recorded.refused];
461
+ return ok(recorded);
271
462
  });
272
463
  }
273
464
  heartbeat(auth, body) {
@@ -275,41 +466,76 @@ var DaemonPlane = class {
275
466
  auth,
276
467
  body,
277
468
  HeartbeatRequest,
278
- (request, device) => {
469
+ async (request, device) => {
279
470
  const now = this.#deps.now();
280
- this.#deps.state.sweep(now);
281
- const known = this.#deps.state.presence(device.runnerId);
282
- const consent = this.#deps.projection.consentFor(
283
- device.owner,
284
- this.#deps.siteId
471
+ await this.#deps.state.sweep();
472
+ await this.#deps.state.seen({
473
+ runnerId: device.runnerId,
474
+ owner: device.owner,
475
+ device: device.device,
476
+ capabilities: request.capabilities,
477
+ // Arrives on the same beat as the matrix and goes stale with it.
478
+ // A daemon that resolves a contended kind stops sending it here,
479
+ // which is what retires the owner's prompt to choose.
480
+ withheld: request.withheld
481
+ });
482
+ const pinned = this.#deps.projection.sitesFor(device.owner);
483
+ const sites = Object.fromEntries(
484
+ pinned.map((record) => [keyId(record.site.identity), record.site])
485
+ );
486
+ const successions = Object.fromEntries(
487
+ pinned.filter((record) => (record.succeeds?.length ?? 0) > 0).map((record) => [
488
+ keyId(record.site.identity),
489
+ {
490
+ succeeds: record.succeeds ?? [],
491
+ ...record.retiringUntil === void 0 ? {} : { retiringUntil: record.retiringUntil }
492
+ }
493
+ ])
285
494
  );
286
- const revoked = consent === null;
287
- if (known) known.revoked = revoked;
288
- const lost = request.activeLeases.filter(({ jobId, leaseId }) => {
289
- const job = this.#deps.state.job(jobId);
290
- return job?.claimedBy?.leaseId !== leaseId;
291
- }).map(({ jobId }) => jobId);
495
+ const rotations = Object.keys(successions).length > 0 ? { successions } : {};
496
+ const awaitingConsent = pinned.filter(
497
+ (record) => !this.#deps.projection.mayRouteFor(device.owner, record.siteId)
498
+ ).map((record) => keyId(record.site.identity));
499
+ if (pinned.length === 0) {
500
+ return ok({
501
+ sites,
502
+ ...rotations,
503
+ awaitingConsent,
504
+ cancel: [],
505
+ lost: request.activeLeases.map((lease) => ({
506
+ jobId: lease.jobId,
507
+ leaseId: lease.leaseId
508
+ })),
509
+ serverTime: now
510
+ });
511
+ }
512
+ const cancel = await this.#deps.state.cancelRequests(device.runnerId);
513
+ const { lost } = await this.#deps.state.renewLeases({
514
+ runnerId: device.runnerId,
515
+ leases: request.activeLeases,
516
+ leaseMs: this.#deps.leaseMs
517
+ });
292
518
  return ok({
293
- revoked,
294
- cancel: [],
295
- leases: [],
519
+ sites,
520
+ ...rotations,
521
+ awaitingConsent,
522
+ cancel,
296
523
  lost,
297
524
  serverTime: now
298
525
  });
299
- },
300
- { allowRevoked: true }
526
+ }
301
527
  );
302
528
  }
303
529
  release(auth, body) {
304
- return this.#authed(auth, body, ReleaseRequest, (request, device) => {
305
- const released = [];
306
- for (const { jobId, leaseId } of request.leases) {
307
- const job = this.#deps.state.job(jobId);
308
- if (!job || job.claimedBy?.runnerId !== device.runnerId) continue;
309
- if (job.claimedBy.leaseId !== leaseId) continue;
310
- this.#deps.state.requeue(job);
311
- released.push(jobId);
312
- }
530
+ return this.#authed(auth, body, ReleaseRequest, async (request, device) => {
531
+ const released = await this.#deps.state.releaseLeases({
532
+ runnerId: device.runnerId,
533
+ leases: request.leases,
534
+ // Was dropped here — cloud_008 §2.1. `reason` is on the wire, the
535
+ // schema's own docstring says an upstream MUST record `refused`, and
536
+ // this handler read every other field.
537
+ reason: request.reason
538
+ });
313
539
  return ok({ released });
314
540
  });
315
541
  }
@@ -349,9 +575,9 @@ function jobRow(job, now) {
349
575
  <td>${job.result ? escape(job.disposition ?? "?") : "<span class='dim'>\u2014</span>"}</td>
350
576
  </tr>`;
351
577
  }
352
- function debugPage(state, now) {
353
- const jobs = state.jobs();
354
- const devices = state.everyone();
578
+ async function debugPage(state, now, routesFor) {
579
+ const jobs = await state.jobs();
580
+ const devices = await state.everyone();
355
581
  return `<!doctype html>
356
582
  <html><head><meta charset="utf-8"><title>byollm relay \u2014 debug</title>
357
583
  <meta http-equiv="refresh" content="1">
@@ -389,7 +615,7 @@ ${devices.length ? devices.map(
389
615
  <td>${escape(d.owner)}</td>
390
616
  <td><code>${escape(fingerprintOf(d.device))}</code></td>
391
617
  <td>${String(Math.max(0, now - d.lastSeenAt))}ms ago</td>
392
- <td>${d.revoked ? "<b style='color:#c00'>revoked</b>" : "active"}</td>
618
+ <td>${routesFor && !routesFor.consents(d.owner) ? "<b style='color:#c00'>no consent</b>" : "active"}</td>
393
619
  </tr>`
394
620
  ).join("\n") : `<tr><td colspan="5" class="dim">no devices connected</td></tr>`}
395
621
  </table>
@@ -397,21 +623,77 @@ ${devices.length ? devices.map(
397
623
  }
398
624
 
399
625
  // src/fixture.ts
400
- import { PublicIdentity as PublicIdentity2 } from "@byollm/protocol";
626
+ import {
627
+ MAX_SUCCESSION_CHAIN,
628
+ Succession,
629
+ PublicIdentity as PublicIdentity2
630
+ } from "@byollm/protocol";
401
631
  import { z as z2 } from "zod";
632
+ var SiteRecord = z2.object({
633
+ /** How the control plane names the site. */
634
+ siteId: z2.string().min(1),
635
+ /**
636
+ * The site's public identity.
637
+ *
638
+ * The relay distributes it and cannot use it: an identity key verifies
639
+ * signatures and seals nothing. This is the key-exchange half of consent
640
+ * (cloud_004 §3), and both endpoints pin what they receive.
641
+ */
642
+ site: PublicIdentity2,
643
+ /**
644
+ * How this site's current key can be traced back to one a daemon holds —
645
+ * byollm_009 Amendment C, ordered oldest last.
646
+ *
647
+ * A list rather than one predecessor because a daemon offline across two
648
+ * rotations holds K1 and meets K3: with a single predecessor it could not
649
+ * verify K3 without K2's record, so it would have to re-pair over
650
+ * housekeeping it did not ask for. The proofs are small, self-verifying,
651
+ * and kept indefinitely for the same reason.
652
+ *
653
+ * **The relay distributes these and cannot mint one.** Each is a signature
654
+ * by a key it does not hold, which is what lets rotation be automatic
655
+ * without becoming the substitution `SITES_LOCALLY_APPROVED` refuses.
656
+ */
657
+ succeeds: z2.array(Succession).max(MAX_SUCCESSION_CHAIN).optional(),
658
+ /**
659
+ * Until when the retired key may still sign work — epoch ms.
660
+ *
661
+ * Absent on a site that has never rotated. The daemon holds its own clock
662
+ * against this for the reason it holds its own allowlist: a projection
663
+ * that could extend the window indefinitely would be a two-key site
664
+ * forever, decided by the party the design does not trust.
665
+ */
666
+ retiringUntil: z2.number().int().positive().optional()
667
+ }).strict();
402
668
  var ConsentRecord = z2.object({
403
669
  /** The user, as the control plane identifies them. */
404
670
  owner: z2.string().min(1),
405
671
  /** Which site this consent is for. Scoped: consent is never global. */
406
672
  siteId: z2.string().min(1),
407
673
  /**
408
- * The site's public identity, as the relay will hand it to the daemon.
674
+ * The consent stands, and nothing routes under it — cloud_008 finding 48.
409
675
  *
410
- * The relay distributes it and cannot use it: an identity key verifies
411
- * signatures and seals nothing. This is the key-exchange half of consent
412
- * (cloud_004 §3), and both endpoints pin what they receive.
676
+ * The disclosure this user agreed to no longer describes their
677
+ * arrangements: they read that their prompts stay on machines they own,
678
+ * and they have since been added to a roster whose owner can read them.
679
+ * Until they have been shown the other sentence and clicked, their work
680
+ * does not move.
681
+ *
682
+ * **A third state, because the two we had are both wrong here.** Dropping
683
+ * the consent makes `consentFor` return null, and the daemon plane reads
684
+ * exactly that as revoked: heartbeat answers `revoked: true` with `lost:
685
+ * all`, and the daemon prints "this runner was revoked" and *deletes its
686
+ * pairing*. So a user whose team changed a setting would be told a human
687
+ * cut them off, lose their pinned keys, and have to re-run `byollm
688
+ * connect` after re-consenting. Under cloud_009 that is worse still: the
689
+ * pairing is keyed by origin, so one stale consent would drop the pairing
690
+ * for every other site reached through that hub.
691
+ *
692
+ * Reporting it as revoked is the same falsehood finding 48 exists to
693
+ * delete, told one layer down. So the record stays, the relationship
694
+ * stays, and the routing stops.
413
695
  */
414
- site: PublicIdentity2
696
+ paused: z2.boolean().default(false)
415
697
  }).strict();
416
698
  var RosterRecord = z2.object({
417
699
  /** Stable id for the group, used only inside the relay. */
@@ -421,21 +703,40 @@ var RosterRecord = z2.object({
421
703
  /** Members who may route to it. The owner is not implicitly a member. */
422
704
  members: z2.array(z2.string().min(1))
423
705
  }).strict();
706
+ var DeviceRecord = z2.object({
707
+ /** Who approved it. */
708
+ owner: z2.string().min(1),
709
+ /** The id the control plane assigned — the device does not choose it. */
710
+ runnerId: z2.string().min(1),
711
+ /** The keys a human compared a fingerprint of before approving. */
712
+ device: PublicIdentity2
713
+ }).strict();
714
+ var RevocationRecord = z2.object({ owner: z2.string().min(1), siteId: z2.string().min(1) }).strict();
424
715
  var RelayFixture = z2.object({
716
+ /** Registered sites, by id. A consent for a site absent here routes not. */
717
+ sites: z2.array(SiteRecord).default([]),
425
718
  consents: z2.array(ConsentRecord),
719
+ devices: z2.array(DeviceRecord).default([]),
426
720
  rosters: z2.array(RosterRecord).default([]),
427
721
  /**
428
- * Owners whose routing is revoked, by `owner:siteId`.
722
+ * Routes that were revoked, as structured pairs.
429
723
  *
430
724
  * A separate list rather than deleting the consent record, because the
431
- * freeze gate needs revocation to be a *fixture edit* observable within
432
- * one heartbeat, and "the row is gone" and "the row was revoked" are
433
- * different things to a reader debugging why routing stopped.
725
+ * freeze gate needs revocation to be an observable *event* rather than an
726
+ * absence — "the row is gone" and "the row was revoked" are different
727
+ * answers to someone debugging why routing stopped.
728
+ *
729
+ * `{owner, siteId}` and never the composite string `"owner:siteId"`. A
730
+ * composite key is a parser waiting to meet an id containing its
731
+ * separator, which is the lesson the composite lease ids taught against
732
+ * Postgres — applied here before it became a contract.
434
733
  */
435
- revoked: z2.array(z2.string().min(1)).default([])
734
+ revoked: z2.array(RevocationRecord).default([])
436
735
  }).strict();
437
736
  var EMPTY_FIXTURE = {
737
+ sites: [],
438
738
  consents: [],
739
+ devices: [],
439
740
  rosters: [],
440
741
  revoked: []
441
742
  };
@@ -448,13 +749,179 @@ var Projection = class {
448
749
  replace(fixture) {
449
750
  this.#fixture = RelayFixture.parse(fixture);
450
751
  }
451
- /** The consent binding this owner to this site, if it exists and stands. */
752
+ /**
753
+ * The site this id names, if the control plane registered it.
754
+ *
755
+ * The only source of a site's public identity in this package. Everything
756
+ * that pins, verifies or seals to a site starts here.
757
+ */
758
+ siteFor(siteId) {
759
+ return this.#fixture.sites.find((s) => s.siteId === siteId) ?? null;
760
+ }
761
+ /**
762
+ * The device this runner id names, if a human approved it.
763
+ *
764
+ * Returns null for a device the control plane does not know, which is how
765
+ * the relay refuses to be the authority on identity.
766
+ */
767
+ deviceFor(runnerId) {
768
+ return this.#fixture.devices.find((d) => d.runnerId === runnerId) ?? null;
769
+ }
770
+ /** The device approved for these exact keys, if any. */
771
+ deviceByFingerprint(identityPublic) {
772
+ return this.#fixture.devices.find((d) => d.device.identity === identityPublic) ?? null;
773
+ }
774
+ /**
775
+ * The consent binding this owner to this site, if it exists and stands.
776
+ *
777
+ * **Liveness, not routing.** A paused consent is returned here: the
778
+ * relationship exists, the daemon is not revoked, the pairing stands. Ask
779
+ * {@link Projection.mayRouteFor} before moving anybody's work — the two
780
+ * questions have different answers and one method answering both is how a
781
+ * paused user would quietly start routing again.
782
+ */
452
783
  consentFor(owner, siteId) {
453
- if (this.#fixture.revoked.includes(`${owner}:${siteId}`)) return null;
784
+ const revoked = this.#fixture.revoked.some(
785
+ (r) => r.owner === owner && r.siteId === siteId
786
+ );
787
+ if (revoked) return null;
454
788
  return this.#fixture.consents.find(
455
789
  (c) => c.owner === owner && c.siteId === siteId
456
790
  ) ?? null;
457
791
  }
792
+ /**
793
+ * Every site this owner may route with — cloud_009 §3.
794
+ *
795
+ * The set a pairing covers, and the set a claim will filter on. Consent
796
+ * decides it, which is the sentence the whole design rests on: a site
797
+ * appears here because a human clicked, never because a site asked to be
798
+ * here and never because a daemon named it.
799
+ *
800
+ * **Paused sites are here, and that is deliberate** — cloud_008 finding 48
801
+ * as ratified. A paused consent routes nothing and keeps its pin: the
802
+ * relationship stands, the key the daemon compared a fingerprint of stays
803
+ * pinned, and re-consenting never costs a re-pair. Written the other way
804
+ * round first, and three of the paused tests failed by refusing to pair at
805
+ * all — which is the trap the finding is about, arriving through the door
806
+ * marked "be stricter".
807
+ *
808
+ * So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
809
+ * questions with different answers, kept apart for the same reason
810
+ * `consentFor` and `mayRouteFor` are: one method answering both is how a
811
+ * paused user quietly starts routing again, or quietly loses their machine.
812
+ *
813
+ * Sorted by site id so two calls with the same projection produce the same
814
+ * answer: this ends up in a pairings file and in a fingerprint list a human
815
+ * compares by eye, and an order that drifts between polls is a diff nobody
816
+ * can read.
817
+ */
818
+ sitesFor(owner) {
819
+ return this.#fixture.sites.filter((site) => this.consentFor(owner, site.siteId) !== null).sort((a, b) => a.siteId < b.siteId ? -1 : 1);
820
+ }
821
+ /**
822
+ * May this owner's work move for this site, right now?
823
+ *
824
+ * Consent exists, was not revoked, and is not paused. The routing question,
825
+ * kept apart from {@link Projection.consentFor}'s liveness one so that a
826
+ * caller has to pick which it means.
827
+ */
828
+ mayRouteFor(owner, siteId) {
829
+ const consent = this.consentFor(owner, siteId);
830
+ return consent !== null && !consent.paused;
831
+ }
832
+ /**
833
+ * Has this owner's relationship *ended* — V1-2?
834
+ *
835
+ * Not "is there nothing to serve". Those were one question until the pre-v1
836
+ * review pulled them apart, and the difference is a machine's pinned keys:
837
+ * an empty answer made the daemon stop, cancel everything and **delete its
838
+ * pairings file**, so a projection that arrived empty or half-written — one
839
+ * bad control-plane push — cost every daemon its pins and every user a
840
+ * re-pair they never asked for.
841
+ *
842
+ * Revocation is a thing somebody did, and this asks for the evidence of it:
843
+ * a revocation record for this owner, and nothing left standing. A
844
+ * projection that simply knows nothing says nothing — the relay answers
845
+ * normally, the daemon serves nobody, and the pairing survives to be
846
+ * correct again when the next push lands.
847
+ *
848
+ * The `revoked` list exists precisely for this and was consulted by
849
+ * nothing. Its own doc said why: "the row is gone" and "the row was
850
+ * revoked" are different answers, and only one of them is a decision.
851
+ */
852
+ revokedOutright(owner) {
853
+ if (this.sitesFor(owner).length > 0) return false;
854
+ return this.#fixture.revoked.some((record) => record.owner === owner);
855
+ }
856
+ /** Whether this pair is consented and paused — what heartbeat reports. */
857
+ pausedFor(owner, siteId) {
858
+ return this.consentFor(owner, siteId)?.paused === true;
859
+ }
860
+ /**
861
+ * Every (site, owner) route this device may run — cloud_009 §3.
862
+ *
863
+ * The claim filter, collapsed to data a store can match on. `routableOwners`
864
+ * was this for one site; the hub needs it for the set, and the shape had to
865
+ * change rather than repeat, because **a set of sites and a set of owners
866
+ * multiply**. A device whose owner consented to site A, serving a roster
867
+ * member who consented to site B, appears in both sets and has no consented
868
+ * route between them. Pairs cannot express a route nobody agreed to.
869
+ *
870
+ * Both halves of the rule are here, and neither was enforced before finding
871
+ * 48's work:
872
+ *
873
+ * - **This machine's owner** must have a live consent for the site, or
874
+ * nothing of that site's runs here at all — including a roster member's
875
+ * work. The roster says whose jobs may land on this machine; consent says
876
+ * whether this machine is available to that site.
877
+ * - **Each job's owner** must have one too. That check did not exist:
878
+ * consent was enforced by the daemon plane's blanket revoked guard, which
879
+ * asks only about the claiming device's owner, so a roster member who
880
+ * never consented to a site could have their work claimed by their admin's
881
+ * machine — `CONSENT_BEFORE_ROUTE` read the other way round.
882
+ */
883
+ routesFor(deviceOwner) {
884
+ const routes = /* @__PURE__ */ new Set();
885
+ for (const site of this.#fixture.sites) {
886
+ if (!this.mayRouteFor(deviceOwner, site.siteId)) continue;
887
+ for (const owner of this.ownersRunnableBy(deviceOwner)) {
888
+ if (!this.mayRouteFor(owner, site.siteId)) continue;
889
+ routes.add(routeKey(site.siteId, owner));
890
+ }
891
+ }
892
+ return routes;
893
+ }
894
+ /**
895
+ * Every owner whose work this device's owner may run, as a list.
896
+ *
897
+ * The same question {@link mayRunFor} answers, asked in the direction a
898
+ * *store* can use. That difference is the crux of making `claim` atomic
899
+ * (cloud_006 §3.2).
900
+ *
901
+ * Today `claim` scans every job and calls `mayRunFor` per candidate, which
902
+ * works because the projection is a local object. A shared routing store
903
+ * cannot do that: the filter has to travel to the store, and a predicate
904
+ * does not travel — you cannot send a closure to Valkey. So the projection
905
+ * is collapsed to **data** here and handed over as a set the store can
906
+ * match on.
907
+ *
908
+ * That the collapse is possible at all is a property of the design worth
909
+ * noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
910
+ * computation over the jobs. If it ever became job-dependent — "may run
911
+ * work of this size", say — an atomic claim would stop being expressible,
912
+ * and that is the moment to argue rather than to add a parameter.
913
+ *
914
+ * The owner is always included: a device runs its owner's work, and the
915
+ * relay checks that before it checks a roster.
916
+ */
917
+ ownersRunnableBy(deviceOwner) {
918
+ const owners = /* @__PURE__ */ new Set([deviceOwner]);
919
+ for (const roster of this.#fixture.rosters) {
920
+ if (roster.owner !== deviceOwner) continue;
921
+ for (const member of roster.members) owners.add(member);
922
+ }
923
+ return [...owners];
924
+ }
458
925
  /**
459
926
  * May this device's owner run work belonging to `jobOwner`?
460
927
  *
@@ -473,9 +940,17 @@ var Projection = class {
473
940
  };
474
941
 
475
942
  // src/site-plane.ts
476
- import { JobStub, SealedEnvelope } from "@byollm/protocol";
943
+ import {
944
+ PROTOCOL_VERSION as PROTOCOL_VERSION2,
945
+ keyId as keyId2,
946
+ JobStub,
947
+ RequestSignature as RequestSignature2,
948
+ SealedEnvelope,
949
+ verifySiteRequest
950
+ } from "@byollm/protocol";
477
951
  import { z as z3 } from "zod";
478
952
  var EnqueueRequest = z3.object({
953
+ protocolVersion: z3.literal(PROTOCOL_VERSION2),
479
954
  siteId: z3.string().min(1),
480
955
  /**
481
956
  * Everything the relay learns about the job.
@@ -487,11 +962,18 @@ var EnqueueRequest = z3.object({
487
962
  stub: JobStub
488
963
  }).strict();
489
964
  var PayloadRequest = z3.object({
965
+ protocolVersion: z3.literal(PROTOCOL_VERSION2),
490
966
  siteId: z3.string().min(1),
491
967
  jobId: z3.string().min(1),
492
968
  /** Sealed to the claiming device. Opaque to us and to the schema. */
493
969
  envelope: SealedEnvelope
494
970
  }).strict();
971
+ var CancelRequest = z3.object({
972
+ protocolVersion: z3.literal(PROTOCOL_VERSION2),
973
+ siteId: z3.string().min(1),
974
+ jobId: z3.string().min(1)
975
+ }).strict();
976
+ var QueryRequest = z3.object({ siteId: z3.string().min(1) }).strict();
495
977
  var ok2 = (body) => ({ status: 200, body });
496
978
  var fail2 = (status, error, message) => ({
497
979
  status,
@@ -502,17 +984,85 @@ var SitePlane = class {
502
984
  constructor(deps) {
503
985
  this.#deps = deps;
504
986
  }
505
- enqueue(body) {
506
- const parsed = EnqueueRequest.safeParse(body);
507
- if (!parsed.success) {
508
- return fail2(400, "bad-request", "enqueue failed schema validation");
987
+ /**
988
+ * Signature first, then the site id, then the work.
989
+ *
990
+ * The caller is whoever the signature says, verified against the key the
991
+ * control plane registered — never whoever the request claims. The `siteId`
992
+ * every request carries is then required to *match* that caller, so the two
993
+ * can never name different sites; a request that says one thing in its
994
+ * signed material and another in its body is refused rather than reconciled.
995
+ *
996
+ * Every endpoint goes through here, including the reads. That is deliberate:
997
+ * an authenticated write plane beside an open read plane would still hand a
998
+ * stranger presence, claims and lease ids, and "who is online right now" is
999
+ * exactly the fact a blind relay is otherwise so careful not to reveal.
1000
+ */
1001
+ async #authed(auth, body, schema, siteIdOf, run) {
1002
+ const signature = RequestSignature2.safeParse(auth.signature);
1003
+ if (!signature.success) {
1004
+ return fail2(401, "unauthorized", "this request is not signed");
509
1005
  }
510
- const job = this.#deps.state.enqueue({
511
- id: parsed.data.stub.id,
512
- siteId: parsed.data.siteId,
513
- stub: parsed.data.stub
514
- });
515
- return ok2({ jobId: job.id, state: job.state });
1006
+ const siteId = signature.data.runnerId;
1007
+ const site = this.#deps.projection.siteFor(siteId);
1008
+ if (!site) {
1009
+ return fail2(401, "unauthorized", "this site is not registered");
1010
+ }
1011
+ const acceptable = [
1012
+ site.site.identity,
1013
+ ...site.retiringUntil !== void 0 && this.#deps.now() < site.retiringUntil ? (site.succeeds ?? []).map((link) => link.identity.identity) : []
1014
+ ];
1015
+ let failure = "bad-signature";
1016
+ for (const identityPublic of acceptable) {
1017
+ failure = verifySiteRequest({
1018
+ identityPublic,
1019
+ endpoint: auth.endpoint,
1020
+ body: auth.rawBody,
1021
+ signature: signature.data,
1022
+ now: this.#deps.now()
1023
+ });
1024
+ if (!failure) break;
1025
+ if (failure === "stale") break;
1026
+ }
1027
+ if (failure === "stale") return clockSkewRefusal(this.#deps.now());
1028
+ if (failure) return fail2(401, "unauthorized", "signature check failed");
1029
+ const parsed = schema.safeParse(body);
1030
+ if (!parsed.success || parsed.data === void 0) {
1031
+ return fail2(400, "bad-request", "request failed schema validation");
1032
+ }
1033
+ if (siteIdOf(parsed.data) !== siteId) {
1034
+ return fail2(403, "forbidden", "that is not your site");
1035
+ }
1036
+ return run(parsed.data, siteId);
1037
+ }
1038
+ enqueue(auth, body) {
1039
+ return this.#authed(
1040
+ auth,
1041
+ body,
1042
+ EnqueueRequest,
1043
+ (request) => request.siteId,
1044
+ async (request, siteId) => {
1045
+ const registered = this.#deps.projection.siteFor(siteId);
1046
+ if (!registered || keyId2(registered.site.identity) !== request.stub.site) {
1047
+ return fail2(
1048
+ 403,
1049
+ // V1-13, and one of the five the ruling itself named: an
1050
+ // identified site claiming another site's stub is `forbidden`.
1051
+ "forbidden",
1052
+ "that stub does not name the site that signed it"
1053
+ );
1054
+ }
1055
+ const job = await this.#deps.state.enqueue({
1056
+ id: request.stub.id,
1057
+ siteId,
1058
+ stub: request.stub
1059
+ });
1060
+ if ("refused" in job) {
1061
+ return fail2(400, "bad-request", "that stub was not accepted");
1062
+ }
1063
+ return ok2({ jobId: job.id, state: job.state });
1064
+ }
1065
+ );
516
1066
  }
517
1067
  /**
518
1068
  * What needs sealing, and who to seal it to.
@@ -522,56 +1072,122 @@ var SitePlane = class {
522
1072
  * a participant: it tells the site an address, and what the site sends to
523
1073
  * that address is unreadable on the way through.
524
1074
  */
525
- pending(siteId) {
526
- this.#deps.state.sweep(this.#deps.now());
527
- const jobs = this.#deps.state.awaiting(siteId).map((job) => ({
528
- jobId: job.id,
529
- // Non-null by construction: `awaiting` only returns claimed jobs. The
530
- // optional chain is here so a future state-machine edit that broke that
531
- // invariant would produce a missing field rather than a crash on the
532
- // routing path.
533
- device: job.claimedBy?.device,
534
- runnerId: job.claimedBy?.runnerId,
535
- leaseId: job.claimedBy?.leaseId,
536
- /** So a site can decline to seal for a claim about to expire. */
537
- awaitingUntil: job.awaitingUntil
538
- }));
539
- return ok2({ jobs });
540
- }
541
- payload(body) {
542
- const parsed = PayloadRequest.safeParse(body);
543
- if (!parsed.success) {
544
- return fail2(400, "bad-request", "payload failed schema validation");
545
- }
546
- const job = this.#deps.state.job(parsed.data.jobId);
547
- if (job?.siteId !== parsed.data.siteId) {
548
- return fail2(404, "not-found", "unknown job");
549
- }
550
- if (job.state !== "awaiting-payload") {
551
- return fail2(409, "too-late", `job is ${job.state}, not awaiting payload`);
552
- }
553
- job.payload = parsed.data.envelope;
554
- job.state = "ready";
555
- delete job.awaitingUntil;
556
- return ok2({ jobId: job.id, state: job.state });
1075
+ pending(auth, siteId) {
1076
+ return this.#authed(
1077
+ auth,
1078
+ { siteId },
1079
+ QueryRequest,
1080
+ (request) => request.siteId,
1081
+ async (_request, site) => {
1082
+ await this.#deps.state.sweep();
1083
+ const jobs = (await this.#deps.state.awaiting(site)).map((job) => ({
1084
+ jobId: job.id,
1085
+ // Non-null by construction: `awaiting` only returns claimed jobs.
1086
+ // The optional chain is here so a future state-machine edit that
1087
+ // broke that invariant would produce a missing field rather than a
1088
+ // crash on the routing path.
1089
+ device: job.claimedBy?.device,
1090
+ runnerId: job.claimedBy?.runnerId,
1091
+ leaseId: job.claimedBy?.leaseId,
1092
+ /** So a site can decline to seal for a claim about to expire. */
1093
+ awaitingUntil: job.awaitingUntil,
1094
+ /**
1095
+ * When the *grant* ends — cloud_008 §0.6.
1096
+ *
1097
+ * Distinct from `awaitingUntil`, which bounds how long this relay
1098
+ * waits for the site to seal. A site adopting the lease into its own
1099
+ * records needs the lease's clock; given the other one it recorded a
1100
+ * grant that expired in seconds, then refused the device's own
1101
+ * result for want of a matching lease.
1102
+ */
1103
+ leaseExpiresAt: job.claimedBy?.leaseExpiresAt
1104
+ }));
1105
+ return ok2({ jobs });
1106
+ }
1107
+ );
1108
+ }
1109
+ /**
1110
+ * The site withdraws a job — cloud_008 §2.2.
1111
+ *
1112
+ * Signed and site-scoped like every other site-plane call. A cancellation
1113
+ * is not a delete: a device already running the job has to be told, and it
1114
+ * hears at its next heartbeat.
1115
+ */
1116
+ cancel(auth, body) {
1117
+ return this.#authed(
1118
+ auth,
1119
+ body,
1120
+ CancelRequest,
1121
+ (request) => request.siteId,
1122
+ async (request, siteId) => {
1123
+ const cancelled = await this.#deps.state.cancel({
1124
+ jobId: request.jobId,
1125
+ siteId
1126
+ });
1127
+ return ok2({ cancelled });
1128
+ }
1129
+ );
1130
+ }
1131
+ payload(auth, body) {
1132
+ return this.#authed(
1133
+ auth,
1134
+ body,
1135
+ PayloadRequest,
1136
+ (request) => request.siteId,
1137
+ async (request, siteId) => {
1138
+ const sealed = await this.#deps.state.seal({
1139
+ jobId: request.jobId,
1140
+ siteId,
1141
+ envelope: request.envelope
1142
+ });
1143
+ if ("refused" in sealed) {
1144
+ return sealed.refused === "not-found" ? fail2(404, "not-found", "unknown job") : fail2(
1145
+ 409,
1146
+ "too-late",
1147
+ `job is ${sealed.was ?? "gone"}, not awaiting payload`
1148
+ );
1149
+ }
1150
+ return ok2({ jobId: request.jobId, state: sealed.state });
1151
+ }
1152
+ );
557
1153
  }
558
1154
  /** Sealed results, for the site to open and verify. */
559
- results(siteId) {
560
- const jobs = this.#deps.state.finished(siteId).map((job) => ({
561
- jobId: job.id,
562
- envelope: job.result,
563
- disposition: job.disposition,
564
- runnerId: job.claimedBy?.runnerId,
565
- /** The grant the site adopted, so it can complete against it. */
566
- leaseId: job.claimedBy?.leaseId,
567
- /**
568
- * Which device ran it, so the site can verify the signature against the
569
- * key it was told to seal to — and so `RESULT_PROVENANCE` can name a
570
- * foreign device rather than guessing (cloud_004 §11.2).
571
- */
572
- device: job.claimedBy?.device
573
- }));
574
- return ok2({ jobs });
1155
+ results(auth, siteId) {
1156
+ return this.#authed(
1157
+ auth,
1158
+ { siteId },
1159
+ QueryRequest,
1160
+ (request) => request.siteId,
1161
+ async (_request, site) => {
1162
+ const jobs = (await this.#deps.state.finished(site)).map((job) => ({
1163
+ jobId: job.id,
1164
+ envelope: job.result,
1165
+ disposition: job.disposition,
1166
+ runnerId: job.claimedBy?.runnerId,
1167
+ /** The grant the site adopted, so it can complete against it. */
1168
+ leaseId: job.claimedBy?.leaseId,
1169
+ /**
1170
+ * Which device ran it, so the site can verify the signature against
1171
+ * the key it was told to seal to — and so `PROVENANCE_NAMES_DEVICE` can
1172
+ * name a foreign device rather than guessing (cloud_004 §11.2).
1173
+ */
1174
+ device: job.claimedBy?.device,
1175
+ /**
1176
+ * Whose machine ran it — cloud_008 §2.5, finding 41.
1177
+ *
1178
+ * The relay has held this since the claim: `claimedBy.owner` is the
1179
+ * owner id the *projection* supplied, in the same namespace the
1180
+ * direct plane's `runnerOwner` uses. The cloud lane was filling that
1181
+ * field with `keyId(device.identity)` instead — a key id where every
1182
+ * other plane puts a user id, so an app comparing provenance across
1183
+ * lanes compared two namespaces for equality and got `false` for
1184
+ * the same person.
1185
+ */
1186
+ runnerOwner: job.claimedBy?.owner
1187
+ }));
1188
+ return ok2({ jobs });
1189
+ }
1190
+ );
575
1191
  }
576
1192
  };
577
1193
 
@@ -583,19 +1199,26 @@ var Relay = class {
583
1199
  #site;
584
1200
  #now;
585
1201
  #basePath;
1202
+ #debug;
586
1203
  constructor(options) {
587
- this.state = new RelayState();
1204
+ this.state = options.store ?? new RelayState({ now: options.now ?? Date.now });
588
1205
  this.projection = new Projection(options.fixture);
589
1206
  this.#now = options.now ?? Date.now;
590
1207
  this.#basePath = (options.basePath ?? "/byollm").replace(/\/+$/, "");
1208
+ this.#debug = options.debug ?? false;
591
1209
  this.#daemon = new DaemonPlane({
592
1210
  state: this.state,
593
1211
  projection: this.projection,
594
1212
  now: this.#now,
595
1213
  leaseMs: options.leaseMs ?? 6e4,
596
- siteId: options.siteId
1214
+ pairingCodes: options.pairingCodes ?? new MemoryPairingCodes(() => this.#now()),
1215
+ ...options.verificationUrl === void 0 ? {} : { verificationUrl: options.verificationUrl }
1216
+ });
1217
+ this.#site = new SitePlane({
1218
+ state: this.state,
1219
+ projection: this.projection,
1220
+ now: this.#now
597
1221
  });
598
- this.#site = new SitePlane({ state: this.state, now: this.#now });
599
1222
  }
600
1223
  /** Replace the projection — a control-plane push, or a fixture edit. */
601
1224
  project(fixture) {
@@ -610,49 +1233,96 @@ var Relay = class {
610
1233
  * site vanished should return to the queue without waiting for someone to
611
1234
  * ask about it.
612
1235
  */
613
- sweep() {
614
- return { requeued: this.state.sweep(this.#now()).map((j) => j.id) };
1236
+ async sweep() {
1237
+ const requeued = await this.state.sweep();
1238
+ return { requeued: requeued.map((j) => j.id) };
615
1239
  }
616
1240
  /** The whole HTTP surface. */
617
1241
  async handle(request) {
618
1242
  const url = new URL(request.url);
619
1243
  const path = url.pathname;
620
1244
  if (path === "/debug" || path === `${this.#basePath}/debug`) {
621
- return new Response(debugPage(this.state, this.#now()), {
622
- headers: { "content-type": "text/html; charset=utf-8" }
623
- });
1245
+ const siteId = url.searchParams.get("site");
1246
+ if (!this.#debug) {
1247
+ return new Response(JSON.stringify({ error: "not-found" }), {
1248
+ status: 404,
1249
+ headers: { "content-type": "application/json" }
1250
+ });
1251
+ }
1252
+ if (siteId === null || this.projection.siteFor(siteId) === null) {
1253
+ return new Response(JSON.stringify({ error: "bad-request" }), {
1254
+ status: 400,
1255
+ headers: { "content-type": "application/json" }
1256
+ });
1257
+ }
1258
+ return new Response(
1259
+ await debugPage(this.state, this.#now(), {
1260
+ siteId,
1261
+ consents: (owner) => this.projection.consentFor(owner, siteId) !== null
1262
+ }),
1263
+ { headers: { "content-type": "text/html; charset=utf-8" } }
1264
+ );
624
1265
  }
625
1266
  const rawBody = request.method === "POST" ? await request.text() : "";
626
1267
  const body = rawBody === "" ? void 0 : safeJson(rawBody);
1268
+ const endpoint = path.slice(path.lastIndexOf("/") + 1);
627
1269
  const auth = {
628
- endpoint: path.slice(path.lastIndexOf("/") + 1),
1270
+ endpoint,
629
1271
  rawBody,
630
- signature: signatureFrom(request.headers)
1272
+ signature: signatureFrom(request.headers, "x-byollm-runner")
631
1273
  };
632
- if (path === "/relay/site/enqueue") return json(this.#site.enqueue(body));
633
- if (path === "/relay/site/payload") return json(this.#site.payload(body));
1274
+ if (path.startsWith("/byollm/") || path.startsWith("/relay/")) {
1275
+ const refusal = checkProtocolVersion({
1276
+ protocolVersion: declaredVersion({ body, query: url.searchParams })
1277
+ });
1278
+ if (refusal) return json({ status: 400, body: refusal });
1279
+ }
1280
+ const siteAuth = {
1281
+ endpoint,
1282
+ rawBody,
1283
+ signature: signatureFrom(request.headers, "x-byollm-site")
1284
+ };
1285
+ if (path === "/relay/site/enqueue") {
1286
+ return json(await this.#site.enqueue(siteAuth, body));
1287
+ }
1288
+ if (path === "/relay/site/payload") {
1289
+ return json(await this.#site.payload(siteAuth, body));
1290
+ }
1291
+ if (path === "/relay/site/cancel") {
1292
+ return json(await this.#site.cancel(siteAuth, body));
1293
+ }
634
1294
  if (path === "/relay/site/pending") {
635
- return json(this.#site.pending(url.searchParams.get("siteId") ?? ""));
1295
+ return json(
1296
+ await this.#site.pending(
1297
+ siteAuth,
1298
+ url.searchParams.get("siteId") ?? ""
1299
+ )
1300
+ );
636
1301
  }
637
1302
  if (path === "/relay/site/results") {
638
- return json(this.#site.results(url.searchParams.get("siteId") ?? ""));
1303
+ return json(
1304
+ await this.#site.results(
1305
+ siteAuth,
1306
+ url.searchParams.get("siteId") ?? ""
1307
+ )
1308
+ );
639
1309
  }
640
1310
  if (!path.startsWith(`${this.#basePath}/`)) {
641
1311
  return json({ status: 404, body: { error: "not-found" } });
642
1312
  }
643
1313
  switch (auth.endpoint) {
644
1314
  case "pair":
645
- return json(this.#daemon.pair(body));
1315
+ return json(await this.#daemon.pair(body));
646
1316
  case "claim":
647
- return json(this.#daemon.claim(auth, body));
1317
+ return json(await this.#daemon.claim(auth, body));
648
1318
  case "fetch":
649
- return json(this.#daemon.fetch(auth, body));
1319
+ return json(await this.#daemon.fetch(auth, body));
650
1320
  case "result":
651
- return json(this.#daemon.result(auth, body));
1321
+ return json(await this.#daemon.result(auth, body));
652
1322
  case "heartbeat":
653
- return json(this.#daemon.heartbeat(auth, body));
1323
+ return json(await this.#daemon.heartbeat(auth, body));
654
1324
  case "release":
655
- return json(this.#daemon.release(auth, body));
1325
+ return json(await this.#daemon.release(auth, body));
656
1326
  default:
657
1327
  return json({ status: 404, body: { error: "not-found" } });
658
1328
  }
@@ -665,8 +1335,8 @@ function safeJson(raw) {
665
1335
  return void 0;
666
1336
  }
667
1337
  }
668
- function signatureFrom(headers) {
669
- const runnerId = headers.get("x-byollm-runner");
1338
+ function signatureFrom(headers, callerHeader) {
1339
+ const runnerId = headers.get(callerHeader);
670
1340
  const issuedAt = headers.get("x-byollm-issued-at");
671
1341
  const signature = headers.get("x-byollm-signature");
672
1342
  if (runnerId === null || issuedAt === null || signature === null) {
@@ -681,12 +1351,22 @@ var json = (result) => new Response(JSON.stringify(result.body), {
681
1351
  export {
682
1352
  AWAITING_PAYLOAD_MS,
683
1353
  ConsentRecord,
1354
+ DeviceRecord,
684
1355
  EMPTY_FIXTURE,
1356
+ MAX_OUTSTANDING_PAIRINGS,
1357
+ MemoryPairingCodes,
1358
+ PAIRING_BUSY_MESSAGE,
1359
+ PAIRING_CODE_TTL_MS,
685
1360
  Projection,
686
1361
  Relay,
687
1362
  RelayFixture as RelayFixtureSchema,
688
1363
  RelayState,
1364
+ RevocationRecord,
689
1365
  RosterRecord,
690
- debugPage
1366
+ SiteRecord,
1367
+ debugPage,
1368
+ newDeviceCode,
1369
+ newUserCode,
1370
+ routeKey
691
1371
  };
692
1372
  //# sourceMappingURL=index.js.map