@byollm/relay 0.1.0-alpha.8 → 0.1.0-alpha.80

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