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

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