@byollm/relay 0.1.0-alpha.9 → 0.1.0-alpha.90

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,128 +1,168 @@
1
+ import {
2
+ AWAITING_PAYLOAD_MS,
3
+ RETRY_AFTER_MS,
4
+ RelayState,
5
+ SEAL_ATTEMPTS_BEFORE_EVICTION,
6
+ routeKey
7
+ } from "./chunk-TOLKQRTU.js";
8
+
9
+ // src/index.ts
10
+ import {
11
+ checkProtocolVersion,
12
+ declaredVersion
13
+ } from "@byollm/protocol";
14
+
1
15
  // src/daemon-plane.ts
2
16
  import {
17
+ PairPollRequest,
18
+ PairStartRequest,
3
19
  ClaimRequest,
4
20
  FetchRequest,
5
21
  HeartbeatRequest,
22
+ MAX_ENVELOPE_BYTES as MAX_ENVELOPE_BYTES2,
6
23
  PROTOCOL_VERSION,
7
24
  ReleaseRequest,
8
25
  ResultRequest,
9
26
  RequestSignature,
27
+ envelopeBytes,
10
28
  keyId,
11
29
  verifyRequest,
12
30
  verifyPublicIdentity,
13
- PublicIdentity
31
+ updateOfferFor,
32
+ checkDaemonFloor,
33
+ UPGRADE_COMMAND,
34
+ PublicIdentity,
35
+ ERROR_STATUS as ERROR_STATUS2
14
36
  } from "@byollm/protocol";
15
- import { randomUUID } from "crypto";
16
37
  import { z } from "zod";
17
38
 
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
- );
39
+ // src/pairing-codes.ts
40
+ import { randomBytes } from "crypto";
41
+ var PAIRING_BUSY_MESSAGE = "too many pairings are in progress right now \u2014 try again in a few minutes";
42
+ var HUMAN_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
43
+ function newUserCode() {
44
+ const groups = Array.from(
45
+ randomBytes(8),
46
+ (byte) => HUMAN_ALPHABET.charAt(byte % HUMAN_ALPHABET.length)
47
+ );
48
+ return `${groups.slice(0, 4).join("")}-${groups.slice(4).join("")}`;
49
+ }
50
+ var newDeviceCode = () => randomBytes(32).toString("base64url");
51
+ var PAIRING_CODE_TTL_MS = 10 * 60 * 1e3;
52
+ var MAX_OUTSTANDING_PAIRINGS = 500;
53
+ var MemoryPairingCodes = class {
54
+ #byDevice = /* @__PURE__ */ new Map();
55
+ #now;
56
+ #capacity;
57
+ constructor(now = Date.now, capacity = MAX_OUTSTANDING_PAIRINGS) {
58
+ this.#now = now;
59
+ this.#capacity = capacity;
64
60
  }
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
- );
61
+ #live(pending) {
62
+ if (!pending) return void 0;
63
+ return pending.expiresAt > this.#now() ? pending : void 0;
70
64
  }
71
- seen(presence) {
72
- const existing = this.#presence.get(presence.runnerId);
73
- if (existing) {
74
- existing.lastSeenAt = presence.lastSeenAt;
75
- return existing;
65
+ put(pending) {
66
+ const now = this.#now();
67
+ for (const [code, held] of this.#byDevice) {
68
+ if (held.expiresAt <= now) this.#byDevice.delete(code);
76
69
  }
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);
70
+ const fingerprint = pending.device.identity;
71
+ for (const [code, held] of this.#byDevice) {
72
+ if (held.device.identity === fingerprint) this.#byDevice.delete(code);
73
+ }
74
+ if (this.#byDevice.size >= this.#capacity)
75
+ return Promise.resolve("at-capacity");
76
+ this.#byDevice.set(pending.deviceCode, pending);
77
+ return Promise.resolve("stored");
83
78
  }
84
- everyone() {
85
- return [...this.#presence.values()];
79
+ byDeviceCode(deviceCode) {
80
+ return Promise.resolve(this.#live(this.#byDevice.get(deviceCode)));
86
81
  }
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
- }
82
+ byUserCode(userCode) {
83
+ const wanted = userCode.trim().toUpperCase();
84
+ for (const pending of this.#byDevice.values()) {
85
+ if (pending.userCode === wanted)
86
+ return Promise.resolve(this.#live(pending));
119
87
  }
120
- return requeued;
88
+ return Promise.resolve(void 0);
89
+ }
90
+ drop(deviceCode) {
91
+ this.#byDevice.delete(deviceCode);
92
+ return Promise.resolve();
121
93
  }
122
94
  };
123
95
 
96
+ // src/refusals.ts
97
+ import {
98
+ tooLargeMessage,
99
+ ERROR_STATUS,
100
+ MAX_CLOCK_SKEW_MS,
101
+ MAX_ENVELOPE_BYTES
102
+ } from "@byollm/protocol";
103
+ function clockSkewRefusal(now) {
104
+ return {
105
+ status: ERROR_STATUS["clock-skew"],
106
+ body: {
107
+ error: "clock-skew",
108
+ message: "this request's timestamp is too far from the server's clock; check the machine's time and try again",
109
+ // So the far side can say *how far off* rather than *that something is
110
+ // wrong*. Not a disclosure: the heartbeat response returns the same
111
+ // value, and so does every `Date` header.
112
+ serverTime: now,
113
+ maxSkewMs: MAX_CLOCK_SKEW_MS
114
+ }
115
+ };
116
+ }
117
+ function tooLargeRefusal(bytes) {
118
+ return {
119
+ status: ERROR_STATUS["bad-request"],
120
+ body: {
121
+ error: "bad-request",
122
+ message: `${tooLargeMessage({ bytes, limit: MAX_ENVELOPE_BYTES })} Every plan has the same ceiling.`
123
+ }
124
+ };
125
+ }
126
+
124
127
  // src/daemon-plane.ts
125
128
  var ok = (body) => ({ status: 200, body });
129
+ var REFUSALS = {
130
+ "not-found": {
131
+ status: 404,
132
+ body: { error: "not-found", message: "unknown job" }
133
+ },
134
+ // `forbidden`, not `unauthorized` — V1-13. Both of these are an
135
+ // *identified* caller being refused, which is what 403 means and what the
136
+ // table says `forbidden` is for; `unauthorized` is 401 and means "we do not
137
+ // know who you are". Served as 403 with a 401's code, a revoked daemon and
138
+ // an unsigned one looked alike in every log and every client branch, and
139
+ // "check your keys" is the wrong advice for both in opposite directions.
140
+ "not-holder": {
141
+ status: 403,
142
+ body: {
143
+ error: "forbidden",
144
+ message: "this runner does not hold the job"
145
+ }
146
+ },
147
+ "stale-lease": {
148
+ status: 403,
149
+ body: { error: "forbidden", message: "that lease is no longer current" }
150
+ },
151
+ "not-ready": {
152
+ status: 409,
153
+ body: {
154
+ error: "not-ready",
155
+ message: "the site has not sealed this job yet"
156
+ }
157
+ },
158
+ // Also a 409, and deliberately a different code: `not-ready` means keep
159
+ // asking and this means stop. A daemon that read them as one would poll a
160
+ // finished job until its lease ran out.
161
+ terminal: {
162
+ status: 409,
163
+ body: { error: "too-late", message: "this job has already finished" }
164
+ }
165
+ };
126
166
  var fail = (status, error, message) => ({
127
167
  status,
128
168
  body: { error, message }
@@ -142,7 +182,11 @@ var DaemonPlane = class {
142
182
  * relay that substituted its own identity here could inject work — and would
143
183
  * need a private key to do it, which is why it has none.
144
184
  */
145
- pair(body) {
185
+ async pair(body) {
186
+ const start = PairStartRequest.safeParse(body);
187
+ if (start.success) return this.#pairStart(start.data);
188
+ const poll = PairPollRequest.safeParse(body);
189
+ if (poll.success) return this.#pairPoll(poll.data);
146
190
  const parsed = PairFixtureRequest.safeParse(body);
147
191
  if (!parsed.success) {
148
192
  return fail(400, "bad-request", "pair request failed schema validation");
@@ -150,16 +194,9 @@ var DaemonPlane = class {
150
194
  if (!verifyPublicIdentity(parsed.data.device)) {
151
195
  return fail(400, "bad-request", "the device identity is not consistent");
152
196
  }
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");
197
+ const sites = this.#deps.projection.sitesFor(parsed.data.owner);
198
+ if (sites.length === 0) {
199
+ return fail(403, "forbidden", "no consent record for this user");
163
200
  }
164
201
  const approved = this.#deps.projection.deviceByFingerprint(
165
202
  parsed.data.device.identity
@@ -167,36 +204,167 @@ var DaemonPlane = class {
167
204
  if (!approved) {
168
205
  return fail(
169
206
  403,
170
- "unauthorized",
207
+ "forbidden",
171
208
  "this device has not been approved by its owner"
172
209
  );
173
210
  }
174
211
  if (approved.owner !== parsed.data.owner) {
175
- return fail(403, "unauthorized", "this device belongs to another owner");
212
+ return fail(403, "forbidden", "this device belongs to another owner");
176
213
  }
177
214
  const runnerId = approved.runnerId;
178
- this.#deps.state.seen({
215
+ await this.#deps.state.seen({
179
216
  runnerId,
180
217
  owner: parsed.data.owner,
181
218
  device: parsed.data.device,
182
- lastSeenAt: this.#deps.now()
219
+ // The fixture exchange carries no matrix — it models a consent that
220
+ // arrived from a file, not a daemon describing itself. Empty until the
221
+ // first heartbeat, which is seconds away and is the authority anyway.
222
+ capabilities: [],
223
+ withheld: []
183
224
  });
184
225
  return ok({
185
226
  protocolVersion: PROTOCOL_VERSION,
186
227
  runnerId,
187
- /** The *site's* key. See the note above — this is load-bearing. */
188
- site: site.site
228
+ /**
229
+ * The *sites'* keys. See the note above — this is load-bearing.
230
+ *
231
+ * The set this owner has consented to, keyed by each site's identity
232
+ * key id (cloud_009 §5). Paused sites are here: a paused consent keeps
233
+ * its pin so re-consenting never costs a re-pair, and what it does not
234
+ * do is route.
235
+ */
236
+ sites: Object.fromEntries(
237
+ sites.map((record) => [keyId(record.site.identity), record.site])
238
+ ),
239
+ // The key every later grant is checked against — Amendment J. Sent
240
+ // here and nowhere else: pairing is the ceremony where a human is
241
+ // already deciding whether to trust this upstream, so a key learned
242
+ // here rides a decision that has been made rather than inventing one.
243
+ ...this.#deps.controlPlanePublic === void 0 ? {} : { controlPlanePublic: this.#deps.controlPlanePublic }
244
+ });
245
+ }
246
+ /**
247
+ * Mint a code, remember the keys it stands for, and send the human away.
248
+ *
249
+ * Nothing is decided here. The relay holds an assertion — "this keypair
250
+ * would like to be a machine" — for ten minutes, and the decision happens
251
+ * where the person is signed in.
252
+ */
253
+ async #pairStart(request) {
254
+ const codes = this.#deps.pairingCodes;
255
+ const verificationUrl = this.#deps.verificationUrl;
256
+ if (!codes || verificationUrl === void 0) {
257
+ return fail(
258
+ 501,
259
+ "bad-request",
260
+ "this relay does not offer device-code pairing"
261
+ );
262
+ }
263
+ if (!verifyPublicIdentity(request.device)) {
264
+ return fail(400, "bad-request", "the device identity is not consistent");
265
+ }
266
+ const pending = {
267
+ deviceCode: newDeviceCode(),
268
+ userCode: newUserCode(),
269
+ device: request.device,
270
+ label: request.daemon.label,
271
+ platform: request.daemon.platform,
272
+ capabilities: request.capabilities,
273
+ expiresAt: this.#deps.now() + PAIRING_CODE_TTL_MS
274
+ };
275
+ if (await codes.put(pending) === "at-capacity") {
276
+ return fail(
277
+ ERROR_STATUS2["rate-limited"],
278
+ "rate-limited",
279
+ PAIRING_BUSY_MESSAGE
280
+ );
281
+ }
282
+ return ok({
283
+ deviceCode: pending.deviceCode,
284
+ userCode: pending.userCode,
285
+ verificationUrl,
286
+ expiresAt: pending.expiresAt,
287
+ // Two seconds: fast enough that approving feels immediate, slow enough
288
+ // that a forgotten terminal is not a load generator.
289
+ pollIntervalMs: 2e3
290
+ });
291
+ }
292
+ /**
293
+ * Has anybody approved this keypair yet?
294
+ *
295
+ * The answer comes from the **projection** — the control plane's own record
296
+ * of devices a human approved — and never from a flag set here. That is the
297
+ * whole shape of the fence: the dashboard writes the approval to its own
298
+ * database, the hub's projection catches up within a poll, and this notices.
299
+ * No write crosses in either direction.
300
+ */
301
+ async #pairPoll(request) {
302
+ const codes = this.#deps.pairingCodes;
303
+ if (!codes) {
304
+ return fail(
305
+ 501,
306
+ "bad-request",
307
+ "this relay does not offer device-code pairing"
308
+ );
309
+ }
310
+ const pending = await codes.byDeviceCode(request.deviceCode);
311
+ if (!pending) return ok({ status: "expired" });
312
+ const approved = this.#deps.projection.deviceByFingerprint(
313
+ pending.device.identity
314
+ );
315
+ if (!approved) return ok({ status: "pending" });
316
+ const sites = this.#deps.projection.sitesFor(approved.owner);
317
+ await this.#deps.state.seen({
318
+ runnerId: approved.runnerId,
319
+ owner: approved.owner,
320
+ device: pending.device,
321
+ // What this machine said it could run when it asked to pair, so the
322
+ // approval screen and the machines page have an answer in the same
323
+ // moment the device appears. The next heartbeat replaces it.
324
+ capabilities: pending.capabilities,
325
+ // A pairing request describes what a machine *can* run, not what it is
326
+ // holding back — the daemon resolves defaults locally, and the first
327
+ // heartbeat is where that answer arrives.
328
+ withheld: []
329
+ });
330
+ await codes.drop(pending.deviceCode);
331
+ return ok({
332
+ status: "approved",
333
+ runnerId: approved.runnerId,
334
+ owner: approved.owner,
335
+ sites: Object.fromEntries(
336
+ sites.map((record) => [keyId(record.site.identity), record.site])
337
+ ),
338
+ // The key every later grant is checked against — Amendment J. Sent
339
+ // here and nowhere else: pairing is the ceremony where a human is
340
+ // already deciding whether to trust this upstream, so a key learned
341
+ // here rides a decision that has been made rather than inventing one.
342
+ ...this.#deps.controlPlanePublic === void 0 ? {} : { controlPlanePublic: this.#deps.controlPlanePublic }
189
343
  });
190
344
  }
191
345
  /** Every authenticated call: signature first, then consent, then work. */
192
- #authed(input, body, schema, run, options = {}) {
346
+ async #authed(input, body, schema, run, options = {}) {
193
347
  const signature = RequestSignature.safeParse(input.signature);
194
348
  if (!signature.success) {
195
349
  return fail(401, "unauthorized", "this request is not signed");
196
350
  }
197
- const known = this.#deps.state.presence(signature.data.runnerId);
351
+ let known = await this.#deps.state.presence(signature.data.runnerId);
352
+ let rebuilt = false;
198
353
  if (!known) {
199
- return fail(401, "unauthorized", "this runner is not recognised");
354
+ const approved = this.#deps.projection.deviceFor(signature.data.runnerId);
355
+ if (!approved) {
356
+ return fail(401, "unauthorized", "this runner is not recognised");
357
+ }
358
+ known = {
359
+ ...approved,
360
+ lastSeenAt: this.#deps.now(),
361
+ // Not invented. The heartbeat is the authority on what a machine can
362
+ // run, and it is seconds away; claiming a matrix here would be this
363
+ // file guessing about a backend it cannot see.
364
+ capabilities: [],
365
+ withheld: []
366
+ };
367
+ rebuilt = true;
200
368
  }
201
369
  const failure = verifyRequest({
202
370
  identityPublic: known.device.identity,
@@ -205,8 +373,20 @@ var DaemonPlane = class {
205
373
  signature: signature.data,
206
374
  now: this.#deps.now()
207
375
  });
376
+ if (failure === "stale") return this.#clockSkew();
208
377
  if (failure) return fail(401, "unauthorized", "signature check failed");
209
- const revoked = this.#deps.projection.consentFor(known.owner, this.#deps.siteId) === null;
378
+ if (rebuilt) {
379
+ await this.#deps.state.seen({
380
+ runnerId: known.runnerId,
381
+ owner: known.owner,
382
+ device: known.device,
383
+ capabilities: [],
384
+ // Nothing has described itself yet, so nothing is withheld — the
385
+ // first heartbeat is the authority on both.
386
+ withheld: []
387
+ });
388
+ }
389
+ const revoked = this.#deps.projection.revokedDevice(known.runnerId);
210
390
  if (revoked && options.allowRevoked !== true) {
211
391
  return fail(403, "revoked", "routing for this runner has been revoked");
212
392
  }
@@ -217,43 +397,104 @@ var DaemonPlane = class {
217
397
  }
218
398
  return run(parsed.data, known);
219
399
  }
400
+ /**
401
+ * A clock too far from ours, said plainly and with the number to fix it by.
402
+ *
403
+ * Its own error code rather than a generic `unauthorized`, because it is the
404
+ * one refusal a retry can never fix and an `ntpdate` always can — the same
405
+ * reasoning `version-unsupported` already carries on the daemon side. A
406
+ * daemon that reports this as a generic rejection sends its owner looking at
407
+ * their network.
408
+ *
409
+ * `serverTime` is included so the far side can say *how far off* rather than
410
+ * *that something is wrong*. It is not a disclosure: the heartbeat response
411
+ * returns the same value, and so does every `Date` header.
412
+ */
413
+ #clockSkew() {
414
+ return clockSkewRefusal(this.#deps.now());
415
+ }
220
416
  claim(auth, body) {
221
- return this.#authed(auth, body, ClaimRequest, (request, device) => {
417
+ return this.#authed(auth, body, ClaimRequest, async (request, device) => {
222
418
  if (request.runnerId !== device.runnerId) {
223
- return fail(401, "unauthorized", "runner id does not match the key");
419
+ return fail(403, "forbidden", "runner id does not match the key");
224
420
  }
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)) {
421
+ const granted = await this.#deps.state.claim({
422
+ runnerId: device.runnerId,
423
+ owner: device.owner,
424
+ device: device.device,
425
+ // Every kind this device can run, from any of its services.
426
+ //
427
+ // This used to send only the *defaults* — the rows an unselected job
428
+ // should reach — beside the whole menu as (kind, service) pairs. A
429
+ // job names no service now, so there is no menu to match against and
430
+ // no unselected case for a default to catch: a device answering a
431
+ // kind at all is a candidate, and which of its services runs the work
432
+ // is resolved from the person's mapping when the grant is signed.
433
+ kinds: new Set(request.capabilities.map((c) => c.kind)),
434
+ // The projection, collapsed to data the store can match on — a
435
+ // predicate does not travel, and a set of (site, owner) pairs is
436
+ // what a route is (cloud_009 §3).
437
+ //
438
+ // A paused consent (cloud_008 finding 48) drops its routes and
439
+ // leaves the rest, which is the whole difference between "we are
440
+ // waiting for you to read something about one site" and "a human cut
441
+ // you off from everything".
442
+ routes: this.#deps.projection.routesFor(device.owner),
443
+ max: request.max,
444
+ leaseMs: this.#deps.leaseMs
445
+ });
446
+ const author = this.#deps.authorGrant;
447
+ if (author === void 0) {
448
+ return ok({ jobs: granted, leaseMs: this.#deps.leaseMs });
449
+ }
450
+ const withGrants = [];
451
+ const refused = [];
452
+ const returned = [];
453
+ for (const job of granted) {
454
+ const siteId = this.#deps.projection.siteIdForKey(job.site);
455
+ const decision = siteId === null ? (
456
+ // A stub naming a site this projection cannot place. Transient
457
+ // rather than permanent: the projection is what is behind, not
458
+ // the job.
459
+ { declined: { permanent: false, reason: "unknown-site" } }
460
+ ) : await author({
461
+ job,
462
+ siteId,
463
+ // Straight off the stub. A relay that computed this would be
464
+ // choosing which site a grant says it is for, which is the
465
+ // one thing the signature exists to take out of its hands.
466
+ siteKey: job.site,
467
+ // The site's own purpose, straight off the stub. A relay does
468
+ // not interpret it — it does not hold the manifest and does
469
+ // not hold the mapping; it carries the site's word to the one
470
+ // party that can join them.
471
+ ...job.purpose === void 0 ? {} : { purpose: job.purpose },
472
+ owner: device.owner,
473
+ runnerId: device.runnerId,
474
+ capabilities: request.capabilities
475
+ });
476
+ if (decision.granted === void 0) {
477
+ const lease = { jobId: job.id, leaseId: job.lease.id };
478
+ (decision.declined.permanent ? refused : returned).push(lease);
235
479
  continue;
236
480
  }
237
- const leaseId = randomUUID();
238
- job.state = "awaiting-payload";
239
- job.claimedBy = {
481
+ withGrants.push({ ...job, grant: decision.granted });
482
+ }
483
+ if (refused.length > 0) {
484
+ await this.#deps.state.releaseLeases({
240
485
  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
- }
486
+ leases: refused,
487
+ reason: "refused"
254
488
  });
255
489
  }
256
- return ok({ jobs: granted, leaseMs: this.#deps.leaseMs });
490
+ if (returned.length > 0) {
491
+ await this.#deps.state.releaseLeases({
492
+ runnerId: device.runnerId,
493
+ leases: returned,
494
+ retryAfter: this.#deps.now() + RETRY_AFTER_MS
495
+ });
496
+ }
497
+ return ok({ jobs: withGrants, leaseMs: this.#deps.leaseMs });
257
498
  });
258
499
  }
259
500
  /**
@@ -267,20 +508,14 @@ var DaemonPlane = class {
267
508
  * awaiting-payload clock says otherwise.
268
509
  */
269
510
  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 });
511
+ return this.#authed(auth, body, FetchRequest, async (request, device) => {
512
+ const taken = await this.#deps.state.takePayload({
513
+ jobId: request.jobId,
514
+ runnerId: device.runnerId,
515
+ leaseId: request.leaseId
516
+ });
517
+ if ("refused" in taken) return REFUSALS[taken.refused];
518
+ return ok({ envelope: taken.envelope });
284
519
  });
285
520
  }
286
521
  /**
@@ -293,19 +528,18 @@ var DaemonPlane = class {
293
528
  * only verifiable there.
294
529
  */
295
530
  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 });
531
+ return this.#authed(auth, body, ResultRequest, async (request, device) => {
532
+ const bytes = envelopeBytes(request.envelope);
533
+ if (bytes > MAX_ENVELOPE_BYTES2) return tooLargeRefusal(bytes);
534
+ const recorded = await this.#deps.state.complete({
535
+ jobId: request.jobId,
536
+ runnerId: device.runnerId,
537
+ leaseId: request.leaseId,
538
+ envelope: request.envelope,
539
+ disposition: request.disposition
540
+ });
541
+ if ("refused" in recorded) return REFUSALS[recorded.refused];
542
+ return ok(recorded);
309
543
  });
310
544
  }
311
545
  heartbeat(auth, body) {
@@ -313,41 +547,110 @@ var DaemonPlane = class {
313
547
  auth,
314
548
  body,
315
549
  HeartbeatRequest,
316
- (request, device) => {
550
+ async (request, device) => {
551
+ const belowFloor = this.#deps.daemonFloor === void 0 ? null : checkDaemonFloor({
552
+ daemonVersion: request.daemonVersion,
553
+ floor: this.#deps.daemonFloor,
554
+ upgradeCommand: UPGRADE_COMMAND
555
+ });
556
+ if (belowFloor !== null) {
557
+ return {
558
+ status: ERROR_STATUS2["daemon-below-floor"],
559
+ body: {
560
+ error: belowFloor.error,
561
+ message: belowFloor.message,
562
+ floor: belowFloor.floor
563
+ }
564
+ };
565
+ }
317
566
  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
567
+ await this.#deps.state.sweep();
568
+ await this.#deps.state.seen({
569
+ runnerId: device.runnerId,
570
+ owner: device.owner,
571
+ device: device.device,
572
+ capabilities: request.capabilities,
573
+ // Arrives on the same beat as the matrix and goes stale with it.
574
+ // A daemon that resolves a contended kind stops sending it here,
575
+ // which is what retires the owner's prompt to choose.
576
+ withheld: request.withheld
577
+ });
578
+ const pinned = this.#deps.projection.sitesFor(device.owner);
579
+ const sites = Object.fromEntries(
580
+ pinned.map((record) => [keyId(record.site.identity), record.site])
581
+ );
582
+ const successions = Object.fromEntries(
583
+ pinned.filter((record) => (record.succeeds?.length ?? 0) > 0).map((record) => [
584
+ keyId(record.site.identity),
585
+ {
586
+ succeeds: record.succeeds ?? [],
587
+ ...record.retiringUntil === void 0 ? {} : { retiringUntil: record.retiringUntil }
588
+ }
589
+ ])
323
590
  );
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);
591
+ const rotations = Object.keys(successions).length > 0 ? { successions } : {};
592
+ const awaitingConsent = pinned.filter(
593
+ (record) => !this.#deps.projection.mayRouteFor(device.owner, record.siteId)
594
+ ).map((record) => keyId(record.site.identity));
595
+ if (pinned.length === 0) {
596
+ return ok({
597
+ sites,
598
+ ...rotations,
599
+ ...updateOfferFor({
600
+ offer: this.#deps.updateOffer,
601
+ daemonVersion: request.daemonVersion
602
+ }),
603
+ awaitingConsent,
604
+ cancel: [],
605
+ lost: request.activeLeases.map((lease) => ({
606
+ jobId: lease.jobId,
607
+ leaseId: lease.leaseId
608
+ })),
609
+ serverTime: now
610
+ });
611
+ }
612
+ const cancel = await this.#deps.state.cancelRequests(device.runnerId);
613
+ const { lost } = await this.#deps.state.renewLeases({
614
+ runnerId: device.runnerId,
615
+ leases: request.activeLeases,
616
+ leaseMs: this.#deps.leaseMs
617
+ });
330
618
  return ok({
331
- revoked,
332
- cancel: [],
333
- leases: [],
619
+ sites,
620
+ ...rotations,
621
+ /**
622
+ * The update offer — B053, and the ONLY way this field is set.
623
+ *
624
+ * `updateOfferFor` applies `mayOfferUpdate` inside itself and
625
+ * returns a spreadable object, so there is no `updateTo:` anywhere
626
+ * for a later hand to copy to a third return site. There are
627
+ * already two, which is how a remember-to-check rule fails.
628
+ *
629
+ * HeartbeatResponse is `.strict()`: a wrong emission is not a bad
630
+ * offer, it is every pre-.83 daemon rejecting every heartbeat.
631
+ */
632
+ ...updateOfferFor({
633
+ offer: this.#deps.updateOffer,
634
+ daemonVersion: request.daemonVersion
635
+ }),
636
+ awaitingConsent,
637
+ cancel,
334
638
  lost,
335
639
  serverTime: now
336
640
  });
337
- },
338
- { allowRevoked: true }
641
+ }
339
642
  );
340
643
  }
341
644
  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
- }
645
+ return this.#authed(auth, body, ReleaseRequest, async (request, device) => {
646
+ const released = await this.#deps.state.releaseLeases({
647
+ runnerId: device.runnerId,
648
+ leases: request.leases,
649
+ // Was dropped here — cloud_008 §2.1. `reason` is on the wire, the
650
+ // schema's own docstring says an upstream MUST record `refused`, and
651
+ // this handler read every other field.
652
+ reason: request.reason
653
+ });
351
654
  return ok({ released });
352
655
  });
353
656
  }
@@ -387,9 +690,9 @@ function jobRow(job, now) {
387
690
  <td>${job.result ? escape(job.disposition ?? "?") : "<span class='dim'>\u2014</span>"}</td>
388
691
  </tr>`;
389
692
  }
390
- function debugPage(state, now) {
391
- const jobs = state.jobs();
392
- const devices = state.everyone();
693
+ async function debugPage(state, now, routesFor) {
694
+ const jobs = await state.jobs();
695
+ const devices = await state.everyone();
393
696
  return `<!doctype html>
394
697
  <html><head><meta charset="utf-8"><title>byollm relay \u2014 debug</title>
395
698
  <meta http-equiv="refresh" content="1">
@@ -427,7 +730,7 @@ ${devices.length ? devices.map(
427
730
  <td>${escape(d.owner)}</td>
428
731
  <td><code>${escape(fingerprintOf(d.device))}</code></td>
429
732
  <td>${String(Math.max(0, now - d.lastSeenAt))}ms ago</td>
430
- <td>${d.revoked ? "<b style='color:#c00'>revoked</b>" : "active"}</td>
733
+ <td>${routesFor && !routesFor.consents(d.owner) ? "<b style='color:#c00'>no consent</b>" : "active"}</td>
431
734
  </tr>`
432
735
  ).join("\n") : `<tr><td colspan="5" class="dim">no devices connected</td></tr>`}
433
736
  </table>
@@ -435,7 +738,12 @@ ${devices.length ? devices.map(
435
738
  }
436
739
 
437
740
  // src/fixture.ts
438
- import { PublicIdentity as PublicIdentity2 } from "@byollm/protocol";
741
+ import {
742
+ MAX_SUCCESSION_CHAIN,
743
+ Succession,
744
+ PublicIdentity as PublicIdentity2,
745
+ keyId as keyIdOf
746
+ } from "@byollm/protocol";
439
747
  import { z as z2 } from "zod";
440
748
  var SiteRecord = z2.object({
441
749
  /** How the control plane names the site. */
@@ -447,13 +755,61 @@ var SiteRecord = z2.object({
447
755
  * signatures and seals nothing. This is the key-exchange half of consent
448
756
  * (cloud_004 §3), and both endpoints pin what they receive.
449
757
  */
450
- site: PublicIdentity2
758
+ site: PublicIdentity2,
759
+ /**
760
+ * How this site's current key can be traced back to one a daemon holds —
761
+ * byollm_009 Amendment C, ordered oldest last.
762
+ *
763
+ * A list rather than one predecessor because a daemon offline across two
764
+ * rotations holds K1 and meets K3: with a single predecessor it could not
765
+ * verify K3 without K2's record, so it would have to re-pair over
766
+ * housekeeping it did not ask for. The proofs are small, self-verifying,
767
+ * and kept indefinitely for the same reason.
768
+ *
769
+ * **The relay distributes these and cannot mint one.** Each is a signature
770
+ * by a key it does not hold, which is what lets rotation be automatic
771
+ * without becoming the substitution `SITES_LOCALLY_APPROVED` refuses.
772
+ */
773
+ succeeds: z2.array(Succession).max(MAX_SUCCESSION_CHAIN).optional(),
774
+ /**
775
+ * Until when the retired key may still sign work — epoch ms.
776
+ *
777
+ * Absent on a site that has never rotated. The daemon holds its own clock
778
+ * against this for the reason it holds its own allowlist: a projection
779
+ * that could extend the window indefinitely would be a two-key site
780
+ * forever, decided by the party the design does not trust.
781
+ */
782
+ retiringUntil: z2.number().int().positive().optional()
451
783
  }).strict();
452
784
  var ConsentRecord = z2.object({
453
785
  /** The user, as the control plane identifies them. */
454
786
  owner: z2.string().min(1),
455
787
  /** Which site this consent is for. Scoped: consent is never global. */
456
- siteId: z2.string().min(1)
788
+ siteId: z2.string().min(1),
789
+ /**
790
+ * The consent stands, and nothing routes under it — cloud_008 finding 48.
791
+ *
792
+ * The disclosure this user agreed to no longer describes their
793
+ * arrangements: they read that their prompts stay on machines they own,
794
+ * and they have since been added to a roster whose owner can read them.
795
+ * Until they have been shown the other sentence and clicked, their work
796
+ * does not move.
797
+ *
798
+ * **A third state, because the two we had are both wrong here.** Dropping
799
+ * the consent makes `consentFor` return null, and the daemon plane reads
800
+ * exactly that as revoked: heartbeat answers `revoked: true` with `lost:
801
+ * all`, and the daemon prints "this runner was revoked" and *deletes its
802
+ * pairing*. So a user whose team changed a setting would be told a human
803
+ * cut them off, lose their pinned keys, and have to re-run `byollm
804
+ * connect` after re-consenting. Under cloud_009 that is worse still: the
805
+ * pairing is keyed by origin, so one stale consent would drop the pairing
806
+ * for every other site reached through that hub.
807
+ *
808
+ * Reporting it as revoked is the same falsehood finding 48 exists to
809
+ * delete, told one layer down. So the record stays, the relationship
810
+ * stays, and the routing stops.
811
+ */
812
+ paused: z2.boolean().default(false)
457
813
  }).strict();
458
814
  var RosterRecord = z2.object({
459
815
  /** Stable id for the group, used only inside the relay. */
@@ -469,7 +825,23 @@ var DeviceRecord = z2.object({
469
825
  /** The id the control plane assigned — the device does not choose it. */
470
826
  runnerId: z2.string().min(1),
471
827
  /** The keys a human compared a fingerprint of before approving. */
472
- device: PublicIdentity2
828
+ device: PublicIdentity2,
829
+ /**
830
+ * Whether this device — this one — has been revoked.
831
+ *
832
+ * Optional because a control plane that predates it says nothing, and
833
+ * saying nothing must read as "not revoked": the alternative is a missing
834
+ * field stopping every device on the fleet, which is the failure mode this
835
+ * whole entry exists to end.
836
+ *
837
+ * Device-scoped by ruling (2026-09-03). Revocation used to be answered
838
+ * from the owner's route-revocation list, so an account with any
839
+ * revocation on record and no live site consents refused **every** device
840
+ * it owned — including one paired thirty seconds earlier, whose daemon
841
+ * then deleted its own pairings file. Revoking an experiment and pairing a
842
+ * replacement, which is the ordinary first hour, killed the replacement.
843
+ */
844
+ revoked: z2.boolean().optional()
473
845
  }).strict();
474
846
  var RevocationRecord = z2.object({ owner: z2.string().min(1), siteId: z2.string().min(1) }).strict();
475
847
  var RelayFixture = z2.object({
@@ -531,7 +903,15 @@ var Projection = class {
531
903
  deviceByFingerprint(identityPublic) {
532
904
  return this.#fixture.devices.find((d) => d.device.identity === identityPublic) ?? null;
533
905
  }
534
- /** The consent binding this owner to this site, if it exists and stands. */
906
+ /**
907
+ * The consent binding this owner to this site, if it exists and stands.
908
+ *
909
+ * **Liveness, not routing.** A paused consent is returned here: the
910
+ * relationship exists, the daemon is not revoked, the pairing stands. Ask
911
+ * {@link Projection.mayRouteFor} before moving anybody's work — the two
912
+ * questions have different answers and one method answering both is how a
913
+ * paused user would quietly start routing again.
914
+ */
535
915
  consentFor(owner, siteId) {
536
916
  const revoked = this.#fixture.revoked.some(
537
917
  (r) => r.owner === owner && r.siteId === siteId
@@ -541,6 +921,167 @@ var Projection = class {
541
921
  (c) => c.owner === owner && c.siteId === siteId
542
922
  ) ?? null;
543
923
  }
924
+ /**
925
+ * Every site this owner may route with — cloud_009 §3.
926
+ *
927
+ * The set a pairing covers, and the set a claim will filter on. Consent
928
+ * decides it, which is the sentence the whole design rests on: a site
929
+ * appears here because a human clicked, never because a site asked to be
930
+ * here and never because a daemon named it.
931
+ *
932
+ * **Paused sites are here, and that is deliberate** — cloud_008 finding 48
933
+ * as ratified. A paused consent routes nothing and keeps its pin: the
934
+ * relationship stands, the key the daemon compared a fingerprint of stays
935
+ * pinned, and re-consenting never costs a re-pair. Written the other way
936
+ * round first, and three of the paused tests failed by refusing to pair at
937
+ * all — which is the trap the finding is about, arriving through the door
938
+ * marked "be stricter".
939
+ *
940
+ * So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
941
+ * questions with different answers, kept apart for the same reason
942
+ * `consentFor` and `mayRouteFor` are: one method answering both is how a
943
+ * paused user quietly starts routing again, or quietly loses their machine.
944
+ *
945
+ * Sorted by site id so two calls with the same projection produce the same
946
+ * answer: this ends up in a pairings file and in a fingerprint list a human
947
+ * compares by eye, and an order that drifts between polls is a diff nobody
948
+ * can read.
949
+ */
950
+ sitesFor(owner) {
951
+ return this.#fixture.sites.filter((site) => this.consentFor(owner, site.siteId) !== null).sort((a, b) => a.siteId < b.siteId ? -1 : 1);
952
+ }
953
+ /**
954
+ * Which registered site owns this identity key id?
955
+ *
956
+ * A stub names its site by *key id* (Amendment A §A.3) so a daemon can
957
+ * check it against a pinned key without a lookup. A control plane knows
958
+ * sites by their account id. This is the one place that holds both, so it
959
+ * is the one place that joins them — a control plane asked to accept key
960
+ * ids would need its own copy of the registry.
961
+ *
962
+ * `null` for a key id no registered site carries, which is a projection
963
+ * that is behind rather than a job that is wrong.
964
+ */
965
+ siteIdForKey(keyId3) {
966
+ return this.#fixture.sites.find(
967
+ (record) => keyIdOf(record.site.identity) === keyId3
968
+ )?.siteId ?? null;
969
+ }
970
+ /**
971
+ * May this owner's work move for this site, right now?
972
+ *
973
+ * Consent exists, was not revoked, and is not paused. The routing question,
974
+ * kept apart from {@link Projection.consentFor}'s liveness one so that a
975
+ * caller has to pick which it means.
976
+ */
977
+ mayRouteFor(owner, siteId) {
978
+ const consent = this.consentFor(owner, siteId);
979
+ return consent !== null && !consent.paused;
980
+ }
981
+ /**
982
+ * Has *this device* been revoked — ruled 2026-09-03?
983
+ *
984
+ * This method has now been wrong in both directions, which is why it reads
985
+ * the way it does.
986
+ *
987
+ * First it answered "is there nothing to serve", so an empty or half-written
988
+ * projection was indistinguishable from a human's decision and cost every
989
+ * daemon its pinned keys. The fix asked for evidence — a revocation on
990
+ * record — but asked it of the **owner**, and added
991
+ * `sitesFor(owner).length > 0` as a softener. That produced the opposite
992
+ * failure: an account with any revocation and no live consents refused every
993
+ * device it had, one paired seconds ago included.
994
+ *
995
+ * So: revocation is a fact about one device, never a mood about an owner.
996
+ * This looks up the device that signed the request and reports what the
997
+ * control plane says about *it*.
998
+ *
999
+ * The softener is gone with it. A guard whose answer changes with unrelated
1000
+ * state is not a guard — enabling a site must never be the thing that
1001
+ * un-revokes a machine, and under the old shape it was exactly that.
1002
+ *
1003
+ * A projection that knows nothing about a runner still says nothing here;
1004
+ * `deviceFor` is what refuses an unknown one, with 401, which is a different
1005
+ * sentence for a different situation.
1006
+ *
1007
+ * REVOCATION_IMMEDIATE is untouched: revoking device A still stops A on its
1008
+ * next call. It stops stopping B and C.
1009
+ */
1010
+ revokedDevice(runnerId) {
1011
+ const device = this.#fixture.devices.find(
1012
+ (record) => record.runnerId === runnerId
1013
+ );
1014
+ return device?.revoked === true;
1015
+ }
1016
+ /** Whether this pair is consented and paused — what heartbeat reports. */
1017
+ pausedFor(owner, siteId) {
1018
+ return this.consentFor(owner, siteId)?.paused === true;
1019
+ }
1020
+ /**
1021
+ * Every (site, owner) route this device may run — cloud_009 §3.
1022
+ *
1023
+ * The claim filter, collapsed to data a store can match on. `routableOwners`
1024
+ * was this for one site; the hub needs it for the set, and the shape had to
1025
+ * change rather than repeat, because **a set of sites and a set of owners
1026
+ * multiply**. A device whose owner consented to site A, serving a roster
1027
+ * member who consented to site B, appears in both sets and has no consented
1028
+ * route between them. Pairs cannot express a route nobody agreed to.
1029
+ *
1030
+ * Both halves of the rule are here, and neither was enforced before finding
1031
+ * 48's work:
1032
+ *
1033
+ * - **This machine's owner** must have a live consent for the site, or
1034
+ * nothing of that site's runs here at all — including a roster member's
1035
+ * work. The roster says whose jobs may land on this machine; consent says
1036
+ * whether this machine is available to that site.
1037
+ * - **Each job's owner** must have one too. That check did not exist:
1038
+ * consent was enforced by the daemon plane's blanket revoked guard, which
1039
+ * asks only about the claiming device's owner, so a roster member who
1040
+ * never consented to a site could have their work claimed by their admin's
1041
+ * machine — `CONSENT_BEFORE_ROUTE` read the other way round.
1042
+ */
1043
+ routesFor(deviceOwner) {
1044
+ const routes = /* @__PURE__ */ new Set();
1045
+ for (const site of this.#fixture.sites) {
1046
+ if (!this.mayRouteFor(deviceOwner, site.siteId)) continue;
1047
+ for (const owner of this.ownersRunnableBy(deviceOwner)) {
1048
+ if (!this.mayRouteFor(owner, site.siteId)) continue;
1049
+ routes.add(routeKey(site.siteId, owner));
1050
+ }
1051
+ }
1052
+ return routes;
1053
+ }
1054
+ /**
1055
+ * Every owner whose work this device's owner may run, as a list.
1056
+ *
1057
+ * The same question {@link mayRunFor} answers, asked in the direction a
1058
+ * *store* can use. That difference is the crux of making `claim` atomic
1059
+ * (cloud_006 §3.2).
1060
+ *
1061
+ * Today `claim` scans every job and calls `mayRunFor` per candidate, which
1062
+ * works because the projection is a local object. A shared routing store
1063
+ * cannot do that: the filter has to travel to the store, and a predicate
1064
+ * does not travel — you cannot send a closure to Valkey. So the projection
1065
+ * is collapsed to **data** here and handed over as a set the store can
1066
+ * match on.
1067
+ *
1068
+ * That the collapse is possible at all is a property of the design worth
1069
+ * noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
1070
+ * computation over the jobs. If it ever became job-dependent — "may run
1071
+ * work of this size", say — an atomic claim would stop being expressible,
1072
+ * and that is the moment to argue rather than to add a parameter.
1073
+ *
1074
+ * The owner is always included: a device runs its owner's work, and the
1075
+ * relay checks that before it checks a roster.
1076
+ */
1077
+ ownersRunnableBy(deviceOwner) {
1078
+ const owners = /* @__PURE__ */ new Set([deviceOwner]);
1079
+ for (const roster of this.#fixture.rosters) {
1080
+ if (roster.owner !== deviceOwner) continue;
1081
+ for (const member of roster.members) owners.add(member);
1082
+ }
1083
+ return [...owners];
1084
+ }
544
1085
  /**
545
1086
  * May this device's owner run work belonging to `jobOwner`?
546
1087
  *
@@ -560,6 +1101,10 @@ var Projection = class {
560
1101
 
561
1102
  // src/site-plane.ts
562
1103
  import {
1104
+ MAX_ENVELOPE_BYTES as MAX_ENVELOPE_BYTES3,
1105
+ PROTOCOL_VERSION as PROTOCOL_VERSION2,
1106
+ envelopeBytes as envelopeBytes2,
1107
+ keyId as keyId2,
563
1108
  JobStub,
564
1109
  RequestSignature as RequestSignature2,
565
1110
  SealedEnvelope,
@@ -567,6 +1112,7 @@ import {
567
1112
  } from "@byollm/protocol";
568
1113
  import { z as z3 } from "zod";
569
1114
  var EnqueueRequest = z3.object({
1115
+ protocolVersion: z3.literal(PROTOCOL_VERSION2),
570
1116
  siteId: z3.string().min(1),
571
1117
  /**
572
1118
  * Everything the relay learns about the job.
@@ -578,11 +1124,17 @@ var EnqueueRequest = z3.object({
578
1124
  stub: JobStub
579
1125
  }).strict();
580
1126
  var PayloadRequest = z3.object({
1127
+ protocolVersion: z3.literal(PROTOCOL_VERSION2),
581
1128
  siteId: z3.string().min(1),
582
1129
  jobId: z3.string().min(1),
583
1130
  /** Sealed to the claiming device. Opaque to us and to the schema. */
584
1131
  envelope: SealedEnvelope
585
1132
  }).strict();
1133
+ var CancelRequest = z3.object({
1134
+ protocolVersion: z3.literal(PROTOCOL_VERSION2),
1135
+ siteId: z3.string().min(1),
1136
+ jobId: z3.string().min(1)
1137
+ }).strict();
586
1138
  var QueryRequest = z3.object({ siteId: z3.string().min(1) }).strict();
587
1139
  var ok2 = (body) => ({ status: 200, body });
588
1140
  var fail2 = (status, error, message) => ({
@@ -608,7 +1160,7 @@ var SitePlane = class {
608
1160
  * stranger presence, claims and lease ids, and "who is online right now" is
609
1161
  * exactly the fact a blind relay is otherwise so careful not to reveal.
610
1162
  */
611
- #authed(auth, body, schema, siteIdOf, run) {
1163
+ async #authed(auth, body, schema, siteIdOf, run) {
612
1164
  const signature = RequestSignature2.safeParse(auth.signature);
613
1165
  if (!signature.success) {
614
1166
  return fail2(401, "unauthorized", "this request is not signed");
@@ -618,23 +1170,30 @@ var SitePlane = class {
618
1170
  if (!site) {
619
1171
  return fail2(401, "unauthorized", "this site is not registered");
620
1172
  }
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
- });
1173
+ const acceptable = [
1174
+ site.site.identity,
1175
+ ...site.retiringUntil !== void 0 && this.#deps.now() < site.retiringUntil ? (site.succeeds ?? []).map((link) => link.identity.identity) : []
1176
+ ];
1177
+ let failure = "bad-signature";
1178
+ for (const identityPublic of acceptable) {
1179
+ failure = verifySiteRequest({
1180
+ identityPublic,
1181
+ endpoint: auth.endpoint,
1182
+ body: auth.rawBody,
1183
+ signature: signature.data,
1184
+ now: this.#deps.now()
1185
+ });
1186
+ if (!failure) break;
1187
+ if (failure === "stale") break;
1188
+ }
1189
+ if (failure === "stale") return clockSkewRefusal(this.#deps.now());
628
1190
  if (failure) return fail2(401, "unauthorized", "signature check failed");
629
1191
  const parsed = schema.safeParse(body);
630
1192
  if (!parsed.success || parsed.data === void 0) {
631
1193
  return fail2(400, "bad-request", "request failed schema validation");
632
1194
  }
633
1195
  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");
1196
+ return fail2(403, "forbidden", "that is not your site");
638
1197
  }
639
1198
  return run(parsed.data, siteId);
640
1199
  }
@@ -644,12 +1203,61 @@ var SitePlane = class {
644
1203
  body,
645
1204
  EnqueueRequest,
646
1205
  (request) => request.siteId,
647
- (request, siteId) => {
648
- const job = this.#deps.state.enqueue({
1206
+ async (request, siteId) => {
1207
+ const registered = this.#deps.projection.siteFor(siteId);
1208
+ if (!registered || keyId2(registered.site.identity) !== request.stub.site) {
1209
+ return fail2(
1210
+ 403,
1211
+ // V1-13, and one of the five the ruling itself named: an
1212
+ // identified site claiming another site's stub is `forbidden`.
1213
+ "forbidden",
1214
+ "that stub does not name the site that signed it"
1215
+ );
1216
+ }
1217
+ let answer;
1218
+ try {
1219
+ answer = await this.#deps.satisfiable?.({
1220
+ siteId,
1221
+ owner: request.stub.owner,
1222
+ purpose: request.stub.purpose,
1223
+ kind: request.stub.kind
1224
+ });
1225
+ } catch {
1226
+ return fail2(
1227
+ 503,
1228
+ "server-error",
1229
+ "we could not check this just now \u2014 try again shortly"
1230
+ );
1231
+ }
1232
+ if (answer?.verdict === "not-declared") {
1233
+ return fail2(
1234
+ 409,
1235
+ "purpose-not-declared",
1236
+ `this site does not declare ${request.stub.purpose ?? "that purpose"} \u2014 declare it on Developer Sites, and the people who have already connected will each map the new slot before it routes`
1237
+ );
1238
+ }
1239
+ if (answer?.verdict === "unmapped") {
1240
+ return fail2(
1241
+ 409,
1242
+ "slot-unsatisfiable",
1243
+ "nobody has chosen what answers this yet"
1244
+ );
1245
+ }
1246
+ if (answer?.verdict === "waiting") {
1247
+ return fail2(
1248
+ 409,
1249
+ "slot-waiting",
1250
+ "nothing can answer this right now \u2014 try again later"
1251
+ );
1252
+ }
1253
+ const job = await this.#deps.state.enqueue({
649
1254
  id: request.stub.id,
650
1255
  siteId,
651
1256
  stub: request.stub
652
1257
  });
1258
+ if ("refused" in job) {
1259
+ return fail2(400, "bad-request", "that stub was not accepted");
1260
+ }
653
1261
  return ok2({ jobId: job.id, state: job.state });
654
1262
  }
655
1263
  );
@@ -668,9 +1276,9 @@ var SitePlane = class {
668
1276
  { siteId },
669
1277
  QueryRequest,
670
1278
  (request) => request.siteId,
671
- (_request, site) => {
672
- this.#deps.state.sweep(this.#deps.now());
673
- const jobs = this.#deps.state.awaiting(site).map((job) => ({
1279
+ async (_request, site) => {
1280
+ await this.#deps.state.sweep();
1281
+ const jobs = (await this.#deps.state.awaiting(site)).map((job) => ({
674
1282
  jobId: job.id,
675
1283
  // Non-null by construction: `awaiting` only returns claimed jobs.
676
1284
  // The optional chain is here so a future state-machine edit that
@@ -680,34 +1288,66 @@ var SitePlane = class {
680
1288
  runnerId: job.claimedBy?.runnerId,
681
1289
  leaseId: job.claimedBy?.leaseId,
682
1290
  /** So a site can decline to seal for a claim about to expire. */
683
- awaitingUntil: job.awaitingUntil
1291
+ awaitingUntil: job.awaitingUntil,
1292
+ /**
1293
+ * When the *grant* ends — cloud_008 §0.6.
1294
+ *
1295
+ * Distinct from `awaitingUntil`, which bounds how long this relay
1296
+ * waits for the site to seal. A site adopting the lease into its own
1297
+ * records needs the lease's clock; given the other one it recorded a
1298
+ * grant that expired in seconds, then refused the device's own
1299
+ * result for want of a matching lease.
1300
+ */
1301
+ leaseExpiresAt: job.claimedBy?.leaseExpiresAt
684
1302
  }));
685
1303
  return ok2({ jobs });
686
1304
  }
687
1305
  );
688
1306
  }
1307
+ /**
1308
+ * The site withdraws a job — cloud_008 §2.2.
1309
+ *
1310
+ * Signed and site-scoped like every other site-plane call. A cancellation
1311
+ * is not a delete: a device already running the job has to be told, and it
1312
+ * hears at its next heartbeat.
1313
+ */
1314
+ cancel(auth, body) {
1315
+ return this.#authed(
1316
+ auth,
1317
+ body,
1318
+ CancelRequest,
1319
+ (request) => request.siteId,
1320
+ async (request, siteId) => {
1321
+ const cancelled = await this.#deps.state.cancel({
1322
+ jobId: request.jobId,
1323
+ siteId
1324
+ });
1325
+ return ok2({ cancelled });
1326
+ }
1327
+ );
1328
+ }
689
1329
  payload(auth, body) {
690
1330
  return this.#authed(
691
1331
  auth,
692
1332
  body,
693
1333
  PayloadRequest,
694
1334
  (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(
1335
+ async (request, siteId) => {
1336
+ const bytes = envelopeBytes2(request.envelope);
1337
+ if (bytes > MAX_ENVELOPE_BYTES3) return tooLargeRefusal(bytes);
1338
+ const sealed = await this.#deps.state.seal({
1339
+ jobId: request.jobId,
1340
+ siteId,
1341
+ envelope: request.envelope
1342
+ });
1343
+ if ("refused" in sealed) {
1344
+ return sealed.refused === "not-found" ? fail2(404, "not-found", "unknown job") : fail2(
702
1345
  409,
703
1346
  "too-late",
704
- `job is ${job.state}, not awaiting payload`
1347
+ `job is ${sealed.was ?? "gone"}, not awaiting payload`
705
1348
  );
706
1349
  }
707
- job.payload = request.envelope;
708
- job.state = "ready";
709
- delete job.awaitingUntil;
710
- return ok2({ jobId: job.id, state: job.state });
1350
+ return ok2({ jobId: request.jobId, state: sealed.state });
711
1351
  }
712
1352
  );
713
1353
  }
@@ -718,8 +1358,8 @@ var SitePlane = class {
718
1358
  { siteId },
719
1359
  QueryRequest,
720
1360
  (request) => request.siteId,
721
- (_request, site) => {
722
- const jobs = this.#deps.state.finished(site).map((job) => ({
1361
+ async (_request, site) => {
1362
+ const jobs = (await this.#deps.state.finished(site)).map((job) => ({
723
1363
  jobId: job.id,
724
1364
  envelope: job.result,
725
1365
  disposition: job.disposition,
@@ -728,10 +1368,22 @@ var SitePlane = class {
728
1368
  leaseId: job.claimedBy?.leaseId,
729
1369
  /**
730
1370
  * 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
1371
+ * the key it was told to seal to — and so `PROVENANCE_NAMES_DEVICE` can
732
1372
  * name a foreign device rather than guessing (cloud_004 §11.2).
733
1373
  */
734
- device: job.claimedBy?.device
1374
+ device: job.claimedBy?.device,
1375
+ /**
1376
+ * Whose machine ran it — cloud_008 §2.5, finding 41.
1377
+ *
1378
+ * The relay has held this since the claim: `claimedBy.owner` is the
1379
+ * owner id the *projection* supplied, in the same namespace the
1380
+ * direct plane's `runnerOwner` uses. The cloud lane was filling that
1381
+ * field with `keyId(device.identity)` instead — a key id where every
1382
+ * other plane puts a user id, so an app comparing provenance across
1383
+ * lanes compared two namespaces for equality and got `false` for
1384
+ * the same person.
1385
+ */
1386
+ runnerOwner: job.claimedBy?.owner
735
1387
  }));
736
1388
  return ok2({ jobs });
737
1389
  }
@@ -747,23 +1399,35 @@ var Relay = class {
747
1399
  #site;
748
1400
  #now;
749
1401
  #basePath;
1402
+ #debug;
750
1403
  constructor(options) {
751
- this.state = new RelayState();
1404
+ this.state = options.store ?? new RelayState({ now: options.now ?? Date.now });
752
1405
  this.projection = new Projection(options.fixture);
753
1406
  this.#now = options.now ?? Date.now;
754
1407
  this.#basePath = (options.basePath ?? "/byollm").replace(/\/+$/, "");
1408
+ this.#debug = options.debug ?? false;
755
1409
  this.#daemon = new DaemonPlane({
756
1410
  state: this.state,
757
1411
  projection: this.projection,
758
1412
  now: this.#now,
759
1413
  leaseMs: options.leaseMs ?? 6e4,
760
- siteId: options.siteId
1414
+ pairingCodes: options.pairingCodes ?? new MemoryPairingCodes(() => this.#now()),
1415
+ ...options.verificationUrl === void 0 ? {} : { verificationUrl: options.verificationUrl },
1416
+ ...options.controlPlanePublic === void 0 ? {} : { controlPlanePublic: options.controlPlanePublic },
1417
+ ...options.authorGrant === void 0 ? {} : { authorGrant: options.authorGrant },
1418
+ ...options.updateOffer === void 0 ? {} : { updateOffer: options.updateOffer },
1419
+ ...options.daemonFloor === void 0 ? {} : { daemonFloor: options.daemonFloor }
761
1420
  });
1421
+ if (options.controlPlanePublic !== void 0 && options.authorGrant === void 0) {
1422
+ throw new Error(
1423
+ "controlPlanePublic is set but authorGrant is not: devices would be told to expect signed grants that nothing here can produce, and would refuse every job"
1424
+ );
1425
+ }
762
1426
  this.#site = new SitePlane({
763
1427
  state: this.state,
764
1428
  projection: this.projection,
765
1429
  now: this.#now,
766
- routesFor: options.siteId
1430
+ ...options.satisfiable === void 0 ? {} : { satisfiable: options.satisfiable }
767
1431
  });
768
1432
  }
769
1433
  /** Replace the projection — a control-plane push, or a fixture edit. */
@@ -779,17 +1443,35 @@ var Relay = class {
779
1443
  * site vanished should return to the queue without waiting for someone to
780
1444
  * ask about it.
781
1445
  */
782
- sweep() {
783
- return { requeued: this.state.sweep(this.#now()).map((j) => j.id) };
1446
+ async sweep() {
1447
+ const requeued = await this.state.sweep();
1448
+ return { requeued: requeued.map((j) => j.id) };
784
1449
  }
785
1450
  /** The whole HTTP surface. */
786
1451
  async handle(request) {
787
1452
  const url = new URL(request.url);
788
1453
  const path = url.pathname;
789
1454
  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
- });
1455
+ const siteId = url.searchParams.get("site");
1456
+ if (!this.#debug) {
1457
+ return new Response(JSON.stringify({ error: "not-found" }), {
1458
+ status: 404,
1459
+ headers: { "content-type": "application/json" }
1460
+ });
1461
+ }
1462
+ if (siteId === null || this.projection.siteFor(siteId) === null) {
1463
+ return new Response(JSON.stringify({ error: "bad-request" }), {
1464
+ status: 400,
1465
+ headers: { "content-type": "application/json" }
1466
+ });
1467
+ }
1468
+ return new Response(
1469
+ await debugPage(this.state, this.#now(), {
1470
+ siteId,
1471
+ consents: (owner) => this.projection.consentFor(owner, siteId) !== null
1472
+ }),
1473
+ { headers: { "content-type": "text/html; charset=utf-8" } }
1474
+ );
793
1475
  }
794
1476
  const rawBody = request.method === "POST" ? await request.text() : "";
795
1477
  const body = rawBody === "" ? void 0 : safeJson(rawBody);
@@ -799,25 +1481,40 @@ var Relay = class {
799
1481
  rawBody,
800
1482
  signature: signatureFrom(request.headers, "x-byollm-runner")
801
1483
  };
1484
+ if (path.startsWith("/byollm/") || path.startsWith("/relay/")) {
1485
+ const refusal = checkProtocolVersion({
1486
+ protocolVersion: declaredVersion({ body, query: url.searchParams })
1487
+ });
1488
+ if (refusal) return json({ status: 400, body: refusal });
1489
+ }
802
1490
  const siteAuth = {
803
1491
  endpoint,
804
1492
  rawBody,
805
1493
  signature: signatureFrom(request.headers, "x-byollm-site")
806
1494
  };
807
1495
  if (path === "/relay/site/enqueue") {
808
- return json(this.#site.enqueue(siteAuth, body));
1496
+ return json(await this.#site.enqueue(siteAuth, body));
809
1497
  }
810
1498
  if (path === "/relay/site/payload") {
811
- return json(this.#site.payload(siteAuth, body));
1499
+ return json(await this.#site.payload(siteAuth, body));
1500
+ }
1501
+ if (path === "/relay/site/cancel") {
1502
+ return json(await this.#site.cancel(siteAuth, body));
812
1503
  }
813
1504
  if (path === "/relay/site/pending") {
814
1505
  return json(
815
- this.#site.pending(siteAuth, url.searchParams.get("siteId") ?? "")
1506
+ await this.#site.pending(
1507
+ siteAuth,
1508
+ url.searchParams.get("siteId") ?? ""
1509
+ )
816
1510
  );
817
1511
  }
818
1512
  if (path === "/relay/site/results") {
819
1513
  return json(
820
- this.#site.results(siteAuth, url.searchParams.get("siteId") ?? "")
1514
+ await this.#site.results(
1515
+ siteAuth,
1516
+ url.searchParams.get("siteId") ?? ""
1517
+ )
821
1518
  );
822
1519
  }
823
1520
  if (!path.startsWith(`${this.#basePath}/`)) {
@@ -825,17 +1522,17 @@ var Relay = class {
825
1522
  }
826
1523
  switch (auth.endpoint) {
827
1524
  case "pair":
828
- return json(this.#daemon.pair(body));
1525
+ return json(await this.#daemon.pair(body));
829
1526
  case "claim":
830
- return json(this.#daemon.claim(auth, body));
1527
+ return json(await this.#daemon.claim(auth, body));
831
1528
  case "fetch":
832
- return json(this.#daemon.fetch(auth, body));
1529
+ return json(await this.#daemon.fetch(auth, body));
833
1530
  case "result":
834
- return json(this.#daemon.result(auth, body));
1531
+ return json(await this.#daemon.result(auth, body));
835
1532
  case "heartbeat":
836
- return json(this.#daemon.heartbeat(auth, body));
1533
+ return json(await this.#daemon.heartbeat(auth, body));
837
1534
  case "release":
838
- return json(this.#daemon.release(auth, body));
1535
+ return json(await this.#daemon.release(auth, body));
839
1536
  default:
840
1537
  return json({ status: 404, body: { error: "not-found" } });
841
1538
  }
@@ -866,13 +1563,21 @@ export {
866
1563
  ConsentRecord,
867
1564
  DeviceRecord,
868
1565
  EMPTY_FIXTURE,
1566
+ MAX_OUTSTANDING_PAIRINGS,
1567
+ MemoryPairingCodes,
1568
+ PAIRING_BUSY_MESSAGE,
1569
+ PAIRING_CODE_TTL_MS,
869
1570
  Projection,
870
1571
  Relay,
871
1572
  RelayFixture as RelayFixtureSchema,
872
1573
  RelayState,
873
1574
  RevocationRecord,
874
1575
  RosterRecord,
1576
+ SEAL_ATTEMPTS_BEFORE_EVICTION,
875
1577
  SiteRecord,
876
- debugPage
1578
+ debugPage,
1579
+ newDeviceCode,
1580
+ newUserCode,
1581
+ routeKey
877
1582
  };
878
1583
  //# sourceMappingURL=index.js.map