@byollm/relay 0.1.0-alpha.7 → 0.1.0-alpha.70

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,109 +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
- /** Take a stub for routing. The payload is not here and will not be. */
24
- enqueue(input) {
25
- const job = {
26
- id: input.id,
27
- siteId: input.siteId,
28
- stub: input.stub,
29
- state: "queued"
30
- };
31
- this.#jobs.set(job.id, job);
32
- return job;
33
- }
34
- job(jobId) {
35
- return this.#jobs.get(jobId);
36
- }
37
- jobs() {
38
- return [...this.#jobs.values()];
39
- }
40
- /** Jobs a site must seal for, right now. */
41
- awaiting(siteId) {
42
- return this.jobs().filter(
43
- (j) => j.siteId === siteId && j.state === "awaiting-payload"
44
- );
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;
45
56
  }
46
- /** Sealed results waiting to go home. */
47
- finished(siteId) {
48
- return this.jobs().filter(
49
- (j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
50
- );
57
+ #live(pending) {
58
+ if (!pending) return void 0;
59
+ return pending.expiresAt > this.#now() ? pending : void 0;
51
60
  }
52
- seen(presence) {
53
- const existing = this.#presence.get(presence.runnerId);
54
- if (existing) {
55
- existing.lastSeenAt = presence.lastSeenAt;
56
- 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);
57
65
  }
58
- const fresh = { ...presence, revoked: false };
59
- this.#presence.set(presence.runnerId, fresh);
60
- return fresh;
61
- }
62
- presence(runnerId) {
63
- return this.#presence.get(runnerId);
64
- }
65
- everyone() {
66
- return [...this.#presence.values()];
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");
67
74
  }
68
- /**
69
- * Return a job to the queue, forgetting the claim.
70
- *
71
- * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
72
- * why the awaiting-payload timeout is cheap to fire: the worst case is that
73
- * a device did nothing for ten seconds and another one gets a turn.
74
- */
75
- requeue(job) {
76
- job.state = "queued";
77
- delete job.claimedBy;
78
- delete job.awaitingUntil;
79
- delete job.payload;
75
+ byDeviceCode(deviceCode) {
76
+ return Promise.resolve(this.#live(this.#byDevice.get(deviceCode)));
80
77
  }
81
- /**
82
- * Fire whatever the clock says is due, and report it.
83
- *
84
- * Returns the jobs it requeued so a caller can log or surface them — a
85
- * timeout that fires invisibly is indistinguishable from a job that was
86
- * never claimed, and those want very different debugging.
87
- */
88
- sweep(now) {
89
- const requeued = [];
90
- for (const job of this.#jobs.values()) {
91
- if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
92
- this.requeue(job);
93
- requeued.push(job);
94
- }
95
- const lease = job.claimedBy;
96
- if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
97
- this.requeue(job);
98
- requeued.push(job);
99
- }
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));
100
83
  }
101
- return requeued;
84
+ return Promise.resolve(void 0);
85
+ }
86
+ drop(deviceCode) {
87
+ this.#byDevice.delete(deviceCode);
88
+ return Promise.resolve();
102
89
  }
103
90
  };
104
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
+
105
124
  // src/daemon-plane.ts
106
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
+ };
107
163
  var fail = (status, error, message) => ({
108
164
  status,
109
165
  body: { error, message }
@@ -123,7 +179,11 @@ var DaemonPlane = class {
123
179
  * relay that substituted its own identity here could inject work — and would
124
180
  * need a private key to do it, which is why it has none.
125
181
  */
126
- 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);
127
187
  const parsed = PairFixtureRequest.safeParse(body);
128
188
  if (!parsed.success) {
129
189
  return fail(400, "bad-request", "pair request failed schema validation");
@@ -131,12 +191,9 @@ var DaemonPlane = class {
131
191
  if (!verifyPublicIdentity(parsed.data.device)) {
132
192
  return fail(400, "bad-request", "the device identity is not consistent");
133
193
  }
134
- const consent = this.#deps.projection.consentFor(
135
- parsed.data.owner,
136
- this.#deps.siteId
137
- );
138
- if (!consent) {
139
- return fail(403, "unauthorized", "no consent record for this user");
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");
140
197
  }
141
198
  const approved = this.#deps.projection.deviceByFingerprint(
142
199
  parsed.data.device.identity
@@ -144,36 +201,167 @@ var DaemonPlane = class {
144
201
  if (!approved) {
145
202
  return fail(
146
203
  403,
147
- "unauthorized",
204
+ "forbidden",
148
205
  "this device has not been approved by its owner"
149
206
  );
150
207
  }
151
208
  if (approved.owner !== parsed.data.owner) {
152
- return fail(403, "unauthorized", "this device belongs to another owner");
209
+ return fail(403, "forbidden", "this device belongs to another owner");
153
210
  }
154
211
  const runnerId = approved.runnerId;
155
- this.#deps.state.seen({
212
+ await this.#deps.state.seen({
156
213
  runnerId,
157
214
  owner: parsed.data.owner,
158
215
  device: parsed.data.device,
159
- 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: []
160
221
  });
161
222
  return ok({
162
223
  protocolVersion: PROTOCOL_VERSION,
163
224
  runnerId,
164
- /** The *site's* key. See the note above — this is load-bearing. */
165
- site: consent.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 }
166
340
  });
167
341
  }
168
342
  /** Every authenticated call: signature first, then consent, then work. */
169
- #authed(input, body, schema, run, options = {}) {
343
+ async #authed(input, body, schema, run, options = {}) {
170
344
  const signature = RequestSignature.safeParse(input.signature);
171
345
  if (!signature.success) {
172
346
  return fail(401, "unauthorized", "this request is not signed");
173
347
  }
174
- const known = this.#deps.state.presence(signature.data.runnerId);
348
+ let known = await this.#deps.state.presence(signature.data.runnerId);
349
+ let rebuilt = false;
175
350
  if (!known) {
176
- 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;
177
365
  }
178
366
  const failure = verifyRequest({
179
367
  identityPublic: known.device.identity,
@@ -182,8 +370,21 @@ var DaemonPlane = class {
182
370
  signature: signature.data,
183
371
  now: this.#deps.now()
184
372
  });
373
+ if (failure === "stale") return this.#clockSkew();
185
374
  if (failure) return fail(401, "unauthorized", "signature check failed");
186
- 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.revokedOutright(known.owner);
387
+ if (revoked && options.allowRevoked !== true) {
187
388
  return fail(403, "revoked", "routing for this runner has been revoked");
188
389
  }
189
390
  known.lastSeenAt = this.#deps.now();
@@ -193,42 +394,104 @@ var DaemonPlane = class {
193
394
  }
194
395
  return run(parsed.data, known);
195
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
+ }
196
413
  claim(auth, body) {
197
- return this.#authed(auth, body, ClaimRequest, (request, device) => {
414
+ return this.#authed(auth, body, ClaimRequest, async (request, device) => {
198
415
  if (request.runnerId !== device.runnerId) {
199
- return fail(401, "unauthorized", "runner id does not match the key");
416
+ return fail(403, "forbidden", "runner id does not match the key");
200
417
  }
201
- const now = this.#deps.now();
202
- this.#deps.state.sweep(now);
203
- const kinds = new Set(request.capabilities.map((c) => c.kind));
204
- const granted = [];
205
- for (const job of this.#deps.state.jobs()) {
206
- if (granted.length >= request.max) break;
207
- if (job.state !== "queued") continue;
208
- if (!kinds.has(job.stub.kind)) continue;
209
- if (!this.#deps.projection.mayRunFor(device.owner, job.stub.owner)) {
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 });
446
+ }
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);
210
476
  continue;
211
477
  }
212
- const leaseId = randomUUID();
213
- job.state = "awaiting-payload";
214
- job.claimedBy = {
478
+ withGrants.push({ ...job, grant: decision.granted });
479
+ }
480
+ if (refused.length > 0) {
481
+ await this.#deps.state.releaseLeases({
215
482
  runnerId: device.runnerId,
216
- owner: device.owner,
217
- device: device.device,
218
- leaseId,
219
- leaseExpiresAt: now + this.#deps.leaseMs
220
- };
221
- job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
222
- granted.push({
223
- ...job.stub,
224
- lease: {
225
- id: leaseId,
226
- runnerId: device.runnerId,
227
- expiresAt: job.claimedBy.leaseExpiresAt
228
- }
483
+ leases: refused,
484
+ reason: "refused"
229
485
  });
230
486
  }
231
- 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 });
232
495
  });
233
496
  }
234
497
  /**
@@ -242,20 +505,14 @@ var DaemonPlane = class {
242
505
  * awaiting-payload clock says otherwise.
243
506
  */
244
507
  fetch(auth, body) {
245
- return this.#authed(auth, body, FetchRequest, (request, device) => {
246
- const job = this.#deps.state.job(request.jobId);
247
- if (!job) return fail(404, "not-found", "unknown job");
248
- if (job.claimedBy?.runnerId !== device.runnerId) {
249
- return fail(403, "unauthorized", "this runner does not hold the job");
250
- }
251
- if (job.claimedBy.leaseId !== request.leaseId) {
252
- return fail(403, "unauthorized", "that lease is no longer current");
253
- }
254
- if (!job.payload) {
255
- return fail(409, "not-ready", "the site has not sealed this job yet");
256
- }
257
- job.state = "running";
258
- 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 });
259
516
  });
260
517
  }
261
518
  /**
@@ -268,19 +525,18 @@ var DaemonPlane = class {
268
525
  * only verifiable there.
269
526
  */
270
527
  result(auth, body) {
271
- return this.#authed(auth, body, ResultRequest, (request, device) => {
272
- const job = this.#deps.state.job(request.jobId);
273
- if (!job) return fail(404, "not-found", "unknown job");
274
- if (job.claimedBy?.runnerId !== device.runnerId) {
275
- return fail(403, "unauthorized", "this runner does not hold the job");
276
- }
277
- if (job.state === "done") {
278
- return ok({ accepted: false, state: job.state });
279
- }
280
- job.result = request.envelope;
281
- job.disposition = request.disposition;
282
- job.state = "done";
283
- 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);
284
540
  });
285
541
  }
286
542
  heartbeat(auth, body) {
@@ -288,41 +544,76 @@ var DaemonPlane = class {
288
544
  auth,
289
545
  body,
290
546
  HeartbeatRequest,
291
- (request, device) => {
547
+ async (request, device) => {
292
548
  const now = this.#deps.now();
293
- this.#deps.state.sweep(now);
294
- const known = this.#deps.state.presence(device.runnerId);
295
- const consent = this.#deps.projection.consentFor(
296
- device.owner,
297
- 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])
298
563
  );
299
- const revoked = consent === null;
300
- if (known) known.revoked = revoked;
301
- const lost = request.activeLeases.filter(({ jobId, leaseId }) => {
302
- const job = this.#deps.state.job(jobId);
303
- return job?.claimedBy?.leaseId !== leaseId;
304
- }).map(({ jobId }) => jobId);
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
+ ])
572
+ );
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
+ });
305
596
  return ok({
306
- revoked,
307
- cancel: [],
308
- leases: [],
597
+ sites,
598
+ ...rotations,
599
+ awaitingConsent,
600
+ cancel,
309
601
  lost,
310
602
  serverTime: now
311
603
  });
312
- },
313
- { allowRevoked: true }
604
+ }
314
605
  );
315
606
  }
316
607
  release(auth, body) {
317
- return this.#authed(auth, body, ReleaseRequest, (request, device) => {
318
- const released = [];
319
- for (const { jobId, leaseId } of request.leases) {
320
- const job = this.#deps.state.job(jobId);
321
- if (!job || job.claimedBy?.runnerId !== device.runnerId) continue;
322
- if (job.claimedBy.leaseId !== leaseId) continue;
323
- this.#deps.state.requeue(job);
324
- released.push(jobId);
325
- }
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
+ });
326
617
  return ok({ released });
327
618
  });
328
619
  }
@@ -362,9 +653,9 @@ function jobRow(job, now) {
362
653
  <td>${job.result ? escape(job.disposition ?? "?") : "<span class='dim'>\u2014</span>"}</td>
363
654
  </tr>`;
364
655
  }
365
- function debugPage(state, now) {
366
- const jobs = state.jobs();
367
- const devices = state.everyone();
656
+ async function debugPage(state, now, routesFor) {
657
+ const jobs = await state.jobs();
658
+ const devices = await state.everyone();
368
659
  return `<!doctype html>
369
660
  <html><head><meta charset="utf-8"><title>byollm relay \u2014 debug</title>
370
661
  <meta http-equiv="refresh" content="1">
@@ -402,7 +693,7 @@ ${devices.length ? devices.map(
402
693
  <td>${escape(d.owner)}</td>
403
694
  <td><code>${escape(fingerprintOf(d.device))}</code></td>
404
695
  <td>${String(Math.max(0, now - d.lastSeenAt))}ms ago</td>
405
- <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>
406
697
  </tr>`
407
698
  ).join("\n") : `<tr><td colspan="5" class="dim">no devices connected</td></tr>`}
408
699
  </table>
@@ -410,21 +701,78 @@ ${devices.length ? devices.map(
410
701
  }
411
702
 
412
703
  // src/fixture.ts
413
- 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";
414
710
  import { z as z2 } from "zod";
711
+ var SiteRecord = z2.object({
712
+ /** How the control plane names the site. */
713
+ siteId: z2.string().min(1),
714
+ /**
715
+ * The site's public identity.
716
+ *
717
+ * The relay distributes it and cannot use it: an identity key verifies
718
+ * signatures and seals nothing. This is the key-exchange half of consent
719
+ * (cloud_004 §3), and both endpoints pin what they receive.
720
+ */
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()
746
+ }).strict();
415
747
  var ConsentRecord = z2.object({
416
748
  /** The user, as the control plane identifies them. */
417
749
  owner: z2.string().min(1),
418
750
  /** Which site this consent is for. Scoped: consent is never global. */
419
751
  siteId: z2.string().min(1),
420
752
  /**
421
- * The site's public identity, as the relay will hand it to the daemon.
753
+ * The consent stands, and nothing routes under it — cloud_008 finding 48.
422
754
  *
423
- * The relay distributes it and cannot use it: an identity key verifies
424
- * signatures and seals nothing. This is the key-exchange half of consent
425
- * (cloud_004 §3), and both endpoints pin what they receive.
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.
426
774
  */
427
- site: PublicIdentity2
775
+ paused: z2.boolean().default(false)
428
776
  }).strict();
429
777
  var RosterRecord = z2.object({
430
778
  /** Stable id for the group, used only inside the relay. */
@@ -444,6 +792,8 @@ var DeviceRecord = z2.object({
444
792
  }).strict();
445
793
  var RevocationRecord = z2.object({ owner: z2.string().min(1), siteId: z2.string().min(1) }).strict();
446
794
  var RelayFixture = z2.object({
795
+ /** Registered sites, by id. A consent for a site absent here routes not. */
796
+ sites: z2.array(SiteRecord).default([]),
447
797
  consents: z2.array(ConsentRecord),
448
798
  devices: z2.array(DeviceRecord).default([]),
449
799
  rosters: z2.array(RosterRecord).default([]),
@@ -463,6 +813,7 @@ var RelayFixture = z2.object({
463
813
  revoked: z2.array(RevocationRecord).default([])
464
814
  }).strict();
465
815
  var EMPTY_FIXTURE = {
816
+ sites: [],
466
817
  consents: [],
467
818
  devices: [],
468
819
  rosters: [],
@@ -477,6 +828,15 @@ var Projection = class {
477
828
  replace(fixture) {
478
829
  this.#fixture = RelayFixture.parse(fixture);
479
830
  }
831
+ /**
832
+ * The site this id names, if the control plane registered it.
833
+ *
834
+ * The only source of a site's public identity in this package. Everything
835
+ * that pins, verifies or seals to a site starts here.
836
+ */
837
+ siteFor(siteId) {
838
+ return this.#fixture.sites.find((s) => s.siteId === siteId) ?? null;
839
+ }
480
840
  /**
481
841
  * The device this runner id names, if a human approved it.
482
842
  *
@@ -490,7 +850,15 @@ var Projection = class {
490
850
  deviceByFingerprint(identityPublic) {
491
851
  return this.#fixture.devices.find((d) => d.device.identity === identityPublic) ?? null;
492
852
  }
493
- /** The consent binding this owner to this site, if it exists and stands. */
853
+ /**
854
+ * The consent binding this owner to this site, if it exists and stands.
855
+ *
856
+ * **Liveness, not routing.** A paused consent is returned here: the
857
+ * relationship exists, the daemon is not revoked, the pairing stands. Ask
858
+ * {@link Projection.mayRouteFor} before moving anybody's work — the two
859
+ * questions have different answers and one method answering both is how a
860
+ * paused user would quietly start routing again.
861
+ */
494
862
  consentFor(owner, siteId) {
495
863
  const revoked = this.#fixture.revoked.some(
496
864
  (r) => r.owner === owner && r.siteId === siteId
@@ -500,6 +868,156 @@ var Projection = class {
500
868
  (c) => c.owner === owner && c.siteId === siteId
501
869
  ) ?? null;
502
870
  }
871
+ /**
872
+ * Every site this owner may route with — cloud_009 §3.
873
+ *
874
+ * The set a pairing covers, and the set a claim will filter on. Consent
875
+ * decides it, which is the sentence the whole design rests on: a site
876
+ * appears here because a human clicked, never because a site asked to be
877
+ * here and never because a daemon named it.
878
+ *
879
+ * **Paused sites are here, and that is deliberate** — cloud_008 finding 48
880
+ * as ratified. A paused consent routes nothing and keeps its pin: the
881
+ * relationship stands, the key the daemon compared a fingerprint of stays
882
+ * pinned, and re-consenting never costs a re-pair. Written the other way
883
+ * round first, and three of the paused tests failed by refusing to pair at
884
+ * all — which is the trap the finding is about, arriving through the door
885
+ * marked "be stricter".
886
+ *
887
+ * So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
888
+ * questions with different answers, kept apart for the same reason
889
+ * `consentFor` and `mayRouteFor` are: one method answering both is how a
890
+ * paused user quietly starts routing again, or quietly loses their machine.
891
+ *
892
+ * Sorted by site id so two calls with the same projection produce the same
893
+ * answer: this ends up in a pairings file and in a fingerprint list a human
894
+ * compares by eye, and an order that drifts between polls is a diff nobody
895
+ * can read.
896
+ */
897
+ sitesFor(owner) {
898
+ return this.#fixture.sites.filter((site) => this.consentFor(owner, site.siteId) !== null).sort((a, b) => a.siteId < b.siteId ? -1 : 1);
899
+ }
900
+ /**
901
+ * Which registered site owns this identity key id?
902
+ *
903
+ * A stub names its site by *key id* (Amendment A §A.3) so a daemon can
904
+ * check it against a pinned key without a lookup. A control plane knows
905
+ * sites by their account id. This is the one place that holds both, so it
906
+ * is the one place that joins them — a control plane asked to accept key
907
+ * ids would need its own copy of the registry.
908
+ *
909
+ * `null` for a key id no registered site carries, which is a projection
910
+ * that is behind rather than a job that is wrong.
911
+ */
912
+ siteIdForKey(keyId3) {
913
+ return this.#fixture.sites.find(
914
+ (record) => keyIdOf(record.site.identity) === keyId3
915
+ )?.siteId ?? null;
916
+ }
917
+ /**
918
+ * May this owner's work move for this site, right now?
919
+ *
920
+ * Consent exists, was not revoked, and is not paused. The routing question,
921
+ * kept apart from {@link Projection.consentFor}'s liveness one so that a
922
+ * caller has to pick which it means.
923
+ */
924
+ mayRouteFor(owner, siteId) {
925
+ const consent = this.consentFor(owner, siteId);
926
+ return consent !== null && !consent.paused;
927
+ }
928
+ /**
929
+ * Has this owner's relationship *ended* — V1-2?
930
+ *
931
+ * Not "is there nothing to serve". Those were one question until the pre-v1
932
+ * review pulled them apart, and the difference is a machine's pinned keys:
933
+ * an empty answer made the daemon stop, cancel everything and **delete its
934
+ * pairings file**, so a projection that arrived empty or half-written — one
935
+ * bad control-plane push — cost every daemon its pins and every user a
936
+ * re-pair they never asked for.
937
+ *
938
+ * Revocation is a thing somebody did, and this asks for the evidence of it:
939
+ * a revocation record for this owner, and nothing left standing. A
940
+ * projection that simply knows nothing says nothing — the relay answers
941
+ * normally, the daemon serves nobody, and the pairing survives to be
942
+ * correct again when the next push lands.
943
+ *
944
+ * The `revoked` list exists precisely for this and was consulted by
945
+ * nothing. Its own doc said why: "the row is gone" and "the row was
946
+ * revoked" are different answers, and only one of them is a decision.
947
+ */
948
+ revokedOutright(owner) {
949
+ if (this.sitesFor(owner).length > 0) return false;
950
+ return this.#fixture.revoked.some((record) => record.owner === owner);
951
+ }
952
+ /** Whether this pair is consented and paused — what heartbeat reports. */
953
+ pausedFor(owner, siteId) {
954
+ return this.consentFor(owner, siteId)?.paused === true;
955
+ }
956
+ /**
957
+ * Every (site, owner) route this device may run — cloud_009 §3.
958
+ *
959
+ * The claim filter, collapsed to data a store can match on. `routableOwners`
960
+ * was this for one site; the hub needs it for the set, and the shape had to
961
+ * change rather than repeat, because **a set of sites and a set of owners
962
+ * multiply**. A device whose owner consented to site A, serving a roster
963
+ * member who consented to site B, appears in both sets and has no consented
964
+ * route between them. Pairs cannot express a route nobody agreed to.
965
+ *
966
+ * Both halves of the rule are here, and neither was enforced before finding
967
+ * 48's work:
968
+ *
969
+ * - **This machine's owner** must have a live consent for the site, or
970
+ * nothing of that site's runs here at all — including a roster member's
971
+ * work. The roster says whose jobs may land on this machine; consent says
972
+ * whether this machine is available to that site.
973
+ * - **Each job's owner** must have one too. That check did not exist:
974
+ * consent was enforced by the daemon plane's blanket revoked guard, which
975
+ * asks only about the claiming device's owner, so a roster member who
976
+ * never consented to a site could have their work claimed by their admin's
977
+ * machine — `CONSENT_BEFORE_ROUTE` read the other way round.
978
+ */
979
+ routesFor(deviceOwner) {
980
+ const routes = /* @__PURE__ */ new Set();
981
+ for (const site of this.#fixture.sites) {
982
+ if (!this.mayRouteFor(deviceOwner, site.siteId)) continue;
983
+ for (const owner of this.ownersRunnableBy(deviceOwner)) {
984
+ if (!this.mayRouteFor(owner, site.siteId)) continue;
985
+ routes.add(routeKey(site.siteId, owner));
986
+ }
987
+ }
988
+ return routes;
989
+ }
990
+ /**
991
+ * Every owner whose work this device's owner may run, as a list.
992
+ *
993
+ * The same question {@link mayRunFor} answers, asked in the direction a
994
+ * *store* can use. That difference is the crux of making `claim` atomic
995
+ * (cloud_006 §3.2).
996
+ *
997
+ * Today `claim` scans every job and calls `mayRunFor` per candidate, which
998
+ * works because the projection is a local object. A shared routing store
999
+ * cannot do that: the filter has to travel to the store, and a predicate
1000
+ * does not travel — you cannot send a closure to Valkey. So the projection
1001
+ * is collapsed to **data** here and handed over as a set the store can
1002
+ * match on.
1003
+ *
1004
+ * That the collapse is possible at all is a property of the design worth
1005
+ * noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
1006
+ * computation over the jobs. If it ever became job-dependent — "may run
1007
+ * work of this size", say — an atomic claim would stop being expressible,
1008
+ * and that is the moment to argue rather than to add a parameter.
1009
+ *
1010
+ * The owner is always included: a device runs its owner's work, and the
1011
+ * relay checks that before it checks a roster.
1012
+ */
1013
+ ownersRunnableBy(deviceOwner) {
1014
+ const owners = /* @__PURE__ */ new Set([deviceOwner]);
1015
+ for (const roster of this.#fixture.rosters) {
1016
+ if (roster.owner !== deviceOwner) continue;
1017
+ for (const member of roster.members) owners.add(member);
1018
+ }
1019
+ return [...owners];
1020
+ }
503
1021
  /**
504
1022
  * May this device's owner run work belonging to `jobOwner`?
505
1023
  *
@@ -518,9 +1036,19 @@ var Projection = class {
518
1036
  };
519
1037
 
520
1038
  // src/site-plane.ts
521
- import { JobStub, SealedEnvelope } from "@byollm/protocol";
1039
+ import {
1040
+ MAX_ENVELOPE_BYTES as MAX_ENVELOPE_BYTES3,
1041
+ PROTOCOL_VERSION as PROTOCOL_VERSION2,
1042
+ envelopeBytes as envelopeBytes2,
1043
+ keyId as keyId2,
1044
+ JobStub,
1045
+ RequestSignature as RequestSignature2,
1046
+ SealedEnvelope,
1047
+ verifySiteRequest
1048
+ } from "@byollm/protocol";
522
1049
  import { z as z3 } from "zod";
523
1050
  var EnqueueRequest = z3.object({
1051
+ protocolVersion: z3.literal(PROTOCOL_VERSION2),
524
1052
  siteId: z3.string().min(1),
525
1053
  /**
526
1054
  * Everything the relay learns about the job.
@@ -532,11 +1060,18 @@ var EnqueueRequest = z3.object({
532
1060
  stub: JobStub
533
1061
  }).strict();
534
1062
  var PayloadRequest = z3.object({
1063
+ protocolVersion: z3.literal(PROTOCOL_VERSION2),
535
1064
  siteId: z3.string().min(1),
536
1065
  jobId: z3.string().min(1),
537
1066
  /** Sealed to the claiming device. Opaque to us and to the schema. */
538
1067
  envelope: SealedEnvelope
539
1068
  }).strict();
1069
+ var CancelRequest = z3.object({
1070
+ protocolVersion: z3.literal(PROTOCOL_VERSION2),
1071
+ siteId: z3.string().min(1),
1072
+ jobId: z3.string().min(1)
1073
+ }).strict();
1074
+ var QueryRequest = z3.object({ siteId: z3.string().min(1) }).strict();
540
1075
  var ok2 = (body) => ({ status: 200, body });
541
1076
  var fail2 = (status, error, message) => ({
542
1077
  status,
@@ -547,17 +1082,114 @@ var SitePlane = class {
547
1082
  constructor(deps) {
548
1083
  this.#deps = deps;
549
1084
  }
550
- enqueue(body) {
551
- const parsed = EnqueueRequest.safeParse(body);
552
- if (!parsed.success) {
553
- return fail2(400, "bad-request", "enqueue failed schema validation");
1085
+ /**
1086
+ * Signature first, then the site id, then the work.
1087
+ *
1088
+ * The caller is whoever the signature says, verified against the key the
1089
+ * control plane registered — never whoever the request claims. The `siteId`
1090
+ * every request carries is then required to *match* that caller, so the two
1091
+ * can never name different sites; a request that says one thing in its
1092
+ * signed material and another in its body is refused rather than reconciled.
1093
+ *
1094
+ * Every endpoint goes through here, including the reads. That is deliberate:
1095
+ * an authenticated write plane beside an open read plane would still hand a
1096
+ * stranger presence, claims and lease ids, and "who is online right now" is
1097
+ * exactly the fact a blind relay is otherwise so careful not to reveal.
1098
+ */
1099
+ async #authed(auth, body, schema, siteIdOf, run) {
1100
+ const signature = RequestSignature2.safeParse(auth.signature);
1101
+ if (!signature.success) {
1102
+ return fail2(401, "unauthorized", "this request is not signed");
554
1103
  }
555
- const job = this.#deps.state.enqueue({
556
- id: parsed.data.stub.id,
557
- siteId: parsed.data.siteId,
558
- stub: parsed.data.stub
559
- });
560
- return ok2({ jobId: job.id, state: job.state });
1104
+ const siteId = signature.data.runnerId;
1105
+ const site = this.#deps.projection.siteFor(siteId);
1106
+ if (!site) {
1107
+ return fail2(401, "unauthorized", "this site is not registered");
1108
+ }
1109
+ const acceptable = [
1110
+ site.site.identity,
1111
+ ...site.retiringUntil !== void 0 && this.#deps.now() < site.retiringUntil ? (site.succeeds ?? []).map((link) => link.identity.identity) : []
1112
+ ];
1113
+ let failure = "bad-signature";
1114
+ for (const identityPublic of acceptable) {
1115
+ failure = verifySiteRequest({
1116
+ identityPublic,
1117
+ endpoint: auth.endpoint,
1118
+ body: auth.rawBody,
1119
+ signature: signature.data,
1120
+ now: this.#deps.now()
1121
+ });
1122
+ if (!failure) break;
1123
+ if (failure === "stale") break;
1124
+ }
1125
+ if (failure === "stale") return clockSkewRefusal(this.#deps.now());
1126
+ if (failure) return fail2(401, "unauthorized", "signature check failed");
1127
+ const parsed = schema.safeParse(body);
1128
+ if (!parsed.success || parsed.data === void 0) {
1129
+ return fail2(400, "bad-request", "request failed schema validation");
1130
+ }
1131
+ if (siteIdOf(parsed.data) !== siteId) {
1132
+ return fail2(403, "forbidden", "that is not your site");
1133
+ }
1134
+ return run(parsed.data, siteId);
1135
+ }
1136
+ enqueue(auth, body) {
1137
+ return this.#authed(
1138
+ auth,
1139
+ body,
1140
+ EnqueueRequest,
1141
+ (request) => request.siteId,
1142
+ async (request, siteId) => {
1143
+ const registered = this.#deps.projection.siteFor(siteId);
1144
+ if (!registered || keyId2(registered.site.identity) !== request.stub.site) {
1145
+ return fail2(
1146
+ 403,
1147
+ // V1-13, and one of the five the ruling itself named: an
1148
+ // identified site claiming another site's stub is `forbidden`.
1149
+ "forbidden",
1150
+ "that stub does not name the site that signed it"
1151
+ );
1152
+ }
1153
+ let answer;
1154
+ try {
1155
+ answer = await this.#deps.satisfiable?.({
1156
+ siteId,
1157
+ owner: request.stub.owner,
1158
+ purpose: request.stub.purpose,
1159
+ kind: request.stub.kind
1160
+ });
1161
+ } catch {
1162
+ return fail2(
1163
+ 503,
1164
+ "server-error",
1165
+ "we could not check this just now \u2014 try again shortly"
1166
+ );
1167
+ }
1168
+ if (answer?.verdict === "not-declared") {
1169
+ return fail2(
1170
+ 409,
1171
+ "purpose-not-declared",
1172
+ `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`
1173
+ );
1174
+ }
1175
+ if (answer?.verdict === "unmapped") {
1176
+ return fail2(
1177
+ 409,
1178
+ "slot-unsatisfiable",
1179
+ "nobody has chosen what answers this yet"
1180
+ );
1181
+ }
1182
+ const job = await this.#deps.state.enqueue({
1183
+ id: request.stub.id,
1184
+ siteId,
1185
+ stub: request.stub
1186
+ });
1187
+ if ("refused" in job) {
1188
+ return fail2(400, "bad-request", "that stub was not accepted");
1189
+ }
1190
+ return ok2({ jobId: job.id, state: job.state });
1191
+ }
1192
+ );
561
1193
  }
562
1194
  /**
563
1195
  * What needs sealing, and who to seal it to.
@@ -567,56 +1199,124 @@ var SitePlane = class {
567
1199
  * a participant: it tells the site an address, and what the site sends to
568
1200
  * that address is unreadable on the way through.
569
1201
  */
570
- pending(siteId) {
571
- this.#deps.state.sweep(this.#deps.now());
572
- const jobs = this.#deps.state.awaiting(siteId).map((job) => ({
573
- jobId: job.id,
574
- // Non-null by construction: `awaiting` only returns claimed jobs. The
575
- // optional chain is here so a future state-machine edit that broke that
576
- // invariant would produce a missing field rather than a crash on the
577
- // routing path.
578
- device: job.claimedBy?.device,
579
- runnerId: job.claimedBy?.runnerId,
580
- leaseId: job.claimedBy?.leaseId,
581
- /** So a site can decline to seal for a claim about to expire. */
582
- awaitingUntil: job.awaitingUntil
583
- }));
584
- return ok2({ jobs });
585
- }
586
- payload(body) {
587
- const parsed = PayloadRequest.safeParse(body);
588
- if (!parsed.success) {
589
- return fail2(400, "bad-request", "payload failed schema validation");
590
- }
591
- const job = this.#deps.state.job(parsed.data.jobId);
592
- if (job?.siteId !== parsed.data.siteId) {
593
- return fail2(404, "not-found", "unknown job");
594
- }
595
- if (job.state !== "awaiting-payload") {
596
- return fail2(409, "too-late", `job is ${job.state}, not awaiting payload`);
597
- }
598
- job.payload = parsed.data.envelope;
599
- job.state = "ready";
600
- delete job.awaitingUntil;
601
- return ok2({ jobId: job.id, state: job.state });
1202
+ pending(auth, siteId) {
1203
+ return this.#authed(
1204
+ auth,
1205
+ { siteId },
1206
+ QueryRequest,
1207
+ (request) => request.siteId,
1208
+ async (_request, site) => {
1209
+ await this.#deps.state.sweep();
1210
+ const jobs = (await this.#deps.state.awaiting(site)).map((job) => ({
1211
+ jobId: job.id,
1212
+ // Non-null by construction: `awaiting` only returns claimed jobs.
1213
+ // The optional chain is here so a future state-machine edit that
1214
+ // broke that invariant would produce a missing field rather than a
1215
+ // crash on the routing path.
1216
+ device: job.claimedBy?.device,
1217
+ runnerId: job.claimedBy?.runnerId,
1218
+ leaseId: job.claimedBy?.leaseId,
1219
+ /** So a site can decline to seal for a claim about to expire. */
1220
+ awaitingUntil: job.awaitingUntil,
1221
+ /**
1222
+ * When the *grant* ends — cloud_008 §0.6.
1223
+ *
1224
+ * Distinct from `awaitingUntil`, which bounds how long this relay
1225
+ * waits for the site to seal. A site adopting the lease into its own
1226
+ * records needs the lease's clock; given the other one it recorded a
1227
+ * grant that expired in seconds, then refused the device's own
1228
+ * result for want of a matching lease.
1229
+ */
1230
+ leaseExpiresAt: job.claimedBy?.leaseExpiresAt
1231
+ }));
1232
+ return ok2({ jobs });
1233
+ }
1234
+ );
1235
+ }
1236
+ /**
1237
+ * The site withdraws a job — cloud_008 §2.2.
1238
+ *
1239
+ * Signed and site-scoped like every other site-plane call. A cancellation
1240
+ * is not a delete: a device already running the job has to be told, and it
1241
+ * hears at its next heartbeat.
1242
+ */
1243
+ cancel(auth, body) {
1244
+ return this.#authed(
1245
+ auth,
1246
+ body,
1247
+ CancelRequest,
1248
+ (request) => request.siteId,
1249
+ async (request, siteId) => {
1250
+ const cancelled = await this.#deps.state.cancel({
1251
+ jobId: request.jobId,
1252
+ siteId
1253
+ });
1254
+ return ok2({ cancelled });
1255
+ }
1256
+ );
1257
+ }
1258
+ payload(auth, body) {
1259
+ return this.#authed(
1260
+ auth,
1261
+ body,
1262
+ PayloadRequest,
1263
+ (request) => request.siteId,
1264
+ async (request, siteId) => {
1265
+ const bytes = envelopeBytes2(request.envelope);
1266
+ if (bytes > MAX_ENVELOPE_BYTES3) return tooLargeRefusal(bytes);
1267
+ const sealed = await this.#deps.state.seal({
1268
+ jobId: request.jobId,
1269
+ siteId,
1270
+ envelope: request.envelope
1271
+ });
1272
+ if ("refused" in sealed) {
1273
+ return sealed.refused === "not-found" ? fail2(404, "not-found", "unknown job") : fail2(
1274
+ 409,
1275
+ "too-late",
1276
+ `job is ${sealed.was ?? "gone"}, not awaiting payload`
1277
+ );
1278
+ }
1279
+ return ok2({ jobId: request.jobId, state: sealed.state });
1280
+ }
1281
+ );
602
1282
  }
603
1283
  /** Sealed results, for the site to open and verify. */
604
- results(siteId) {
605
- const jobs = this.#deps.state.finished(siteId).map((job) => ({
606
- jobId: job.id,
607
- envelope: job.result,
608
- disposition: job.disposition,
609
- runnerId: job.claimedBy?.runnerId,
610
- /** The grant the site adopted, so it can complete against it. */
611
- leaseId: job.claimedBy?.leaseId,
612
- /**
613
- * Which device ran it, so the site can verify the signature against the
614
- * key it was told to seal to — and so `RESULT_PROVENANCE` can name a
615
- * foreign device rather than guessing (cloud_004 §11.2).
616
- */
617
- device: job.claimedBy?.device
618
- }));
619
- return ok2({ jobs });
1284
+ results(auth, siteId) {
1285
+ return this.#authed(
1286
+ auth,
1287
+ { siteId },
1288
+ QueryRequest,
1289
+ (request) => request.siteId,
1290
+ async (_request, site) => {
1291
+ const jobs = (await this.#deps.state.finished(site)).map((job) => ({
1292
+ jobId: job.id,
1293
+ envelope: job.result,
1294
+ disposition: job.disposition,
1295
+ runnerId: job.claimedBy?.runnerId,
1296
+ /** The grant the site adopted, so it can complete against it. */
1297
+ leaseId: job.claimedBy?.leaseId,
1298
+ /**
1299
+ * Which device ran it, so the site can verify the signature against
1300
+ * the key it was told to seal to — and so `PROVENANCE_NAMES_DEVICE` can
1301
+ * name a foreign device rather than guessing (cloud_004 §11.2).
1302
+ */
1303
+ device: job.claimedBy?.device,
1304
+ /**
1305
+ * Whose machine ran it — cloud_008 §2.5, finding 41.
1306
+ *
1307
+ * The relay has held this since the claim: `claimedBy.owner` is the
1308
+ * owner id the *projection* supplied, in the same namespace the
1309
+ * direct plane's `runnerOwner` uses. The cloud lane was filling that
1310
+ * field with `keyId(device.identity)` instead — a key id where every
1311
+ * other plane puts a user id, so an app comparing provenance across
1312
+ * lanes compared two namespaces for equality and got `false` for
1313
+ * the same person.
1314
+ */
1315
+ runnerOwner: job.claimedBy?.owner
1316
+ }));
1317
+ return ok2({ jobs });
1318
+ }
1319
+ );
620
1320
  }
621
1321
  };
622
1322
 
@@ -628,19 +1328,34 @@ var Relay = class {
628
1328
  #site;
629
1329
  #now;
630
1330
  #basePath;
1331
+ #debug;
631
1332
  constructor(options) {
632
- this.state = new RelayState();
1333
+ this.state = options.store ?? new RelayState({ now: options.now ?? Date.now });
633
1334
  this.projection = new Projection(options.fixture);
634
1335
  this.#now = options.now ?? Date.now;
635
1336
  this.#basePath = (options.basePath ?? "/byollm").replace(/\/+$/, "");
1337
+ this.#debug = options.debug ?? false;
636
1338
  this.#daemon = new DaemonPlane({
637
1339
  state: this.state,
638
1340
  projection: this.projection,
639
1341
  now: this.#now,
640
1342
  leaseMs: options.leaseMs ?? 6e4,
641
- siteId: options.siteId
1343
+ pairingCodes: options.pairingCodes ?? new MemoryPairingCodes(() => this.#now()),
1344
+ ...options.verificationUrl === void 0 ? {} : { verificationUrl: options.verificationUrl },
1345
+ ...options.controlPlanePublic === void 0 ? {} : { controlPlanePublic: options.controlPlanePublic },
1346
+ ...options.authorGrant === void 0 ? {} : { authorGrant: options.authorGrant }
1347
+ });
1348
+ if (options.controlPlanePublic !== void 0 && options.authorGrant === void 0) {
1349
+ throw new Error(
1350
+ "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"
1351
+ );
1352
+ }
1353
+ this.#site = new SitePlane({
1354
+ state: this.state,
1355
+ projection: this.projection,
1356
+ now: this.#now,
1357
+ ...options.satisfiable === void 0 ? {} : { satisfiable: options.satisfiable }
642
1358
  });
643
- this.#site = new SitePlane({ state: this.state, now: this.#now });
644
1359
  }
645
1360
  /** Replace the projection — a control-plane push, or a fixture edit. */
646
1361
  project(fixture) {
@@ -655,49 +1370,96 @@ var Relay = class {
655
1370
  * site vanished should return to the queue without waiting for someone to
656
1371
  * ask about it.
657
1372
  */
658
- sweep() {
659
- return { requeued: this.state.sweep(this.#now()).map((j) => j.id) };
1373
+ async sweep() {
1374
+ const requeued = await this.state.sweep();
1375
+ return { requeued: requeued.map((j) => j.id) };
660
1376
  }
661
1377
  /** The whole HTTP surface. */
662
1378
  async handle(request) {
663
1379
  const url = new URL(request.url);
664
1380
  const path = url.pathname;
665
1381
  if (path === "/debug" || path === `${this.#basePath}/debug`) {
666
- return new Response(debugPage(this.state, this.#now()), {
667
- headers: { "content-type": "text/html; charset=utf-8" }
668
- });
1382
+ const siteId = url.searchParams.get("site");
1383
+ if (!this.#debug) {
1384
+ return new Response(JSON.stringify({ error: "not-found" }), {
1385
+ status: 404,
1386
+ headers: { "content-type": "application/json" }
1387
+ });
1388
+ }
1389
+ if (siteId === null || this.projection.siteFor(siteId) === null) {
1390
+ return new Response(JSON.stringify({ error: "bad-request" }), {
1391
+ status: 400,
1392
+ headers: { "content-type": "application/json" }
1393
+ });
1394
+ }
1395
+ return new Response(
1396
+ await debugPage(this.state, this.#now(), {
1397
+ siteId,
1398
+ consents: (owner) => this.projection.consentFor(owner, siteId) !== null
1399
+ }),
1400
+ { headers: { "content-type": "text/html; charset=utf-8" } }
1401
+ );
669
1402
  }
670
1403
  const rawBody = request.method === "POST" ? await request.text() : "";
671
1404
  const body = rawBody === "" ? void 0 : safeJson(rawBody);
1405
+ const endpoint = path.slice(path.lastIndexOf("/") + 1);
672
1406
  const auth = {
673
- endpoint: path.slice(path.lastIndexOf("/") + 1),
1407
+ endpoint,
1408
+ rawBody,
1409
+ signature: signatureFrom(request.headers, "x-byollm-runner")
1410
+ };
1411
+ if (path.startsWith("/byollm/") || path.startsWith("/relay/")) {
1412
+ const refusal = checkProtocolVersion({
1413
+ protocolVersion: declaredVersion({ body, query: url.searchParams })
1414
+ });
1415
+ if (refusal) return json({ status: 400, body: refusal });
1416
+ }
1417
+ const siteAuth = {
1418
+ endpoint,
674
1419
  rawBody,
675
- signature: signatureFrom(request.headers)
1420
+ signature: signatureFrom(request.headers, "x-byollm-site")
676
1421
  };
677
- if (path === "/relay/site/enqueue") return json(this.#site.enqueue(body));
678
- if (path === "/relay/site/payload") return json(this.#site.payload(body));
1422
+ if (path === "/relay/site/enqueue") {
1423
+ return json(await this.#site.enqueue(siteAuth, body));
1424
+ }
1425
+ if (path === "/relay/site/payload") {
1426
+ return json(await this.#site.payload(siteAuth, body));
1427
+ }
1428
+ if (path === "/relay/site/cancel") {
1429
+ return json(await this.#site.cancel(siteAuth, body));
1430
+ }
679
1431
  if (path === "/relay/site/pending") {
680
- return json(this.#site.pending(url.searchParams.get("siteId") ?? ""));
1432
+ return json(
1433
+ await this.#site.pending(
1434
+ siteAuth,
1435
+ url.searchParams.get("siteId") ?? ""
1436
+ )
1437
+ );
681
1438
  }
682
1439
  if (path === "/relay/site/results") {
683
- return json(this.#site.results(url.searchParams.get("siteId") ?? ""));
1440
+ return json(
1441
+ await this.#site.results(
1442
+ siteAuth,
1443
+ url.searchParams.get("siteId") ?? ""
1444
+ )
1445
+ );
684
1446
  }
685
1447
  if (!path.startsWith(`${this.#basePath}/`)) {
686
1448
  return json({ status: 404, body: { error: "not-found" } });
687
1449
  }
688
1450
  switch (auth.endpoint) {
689
1451
  case "pair":
690
- return json(this.#daemon.pair(body));
1452
+ return json(await this.#daemon.pair(body));
691
1453
  case "claim":
692
- return json(this.#daemon.claim(auth, body));
1454
+ return json(await this.#daemon.claim(auth, body));
693
1455
  case "fetch":
694
- return json(this.#daemon.fetch(auth, body));
1456
+ return json(await this.#daemon.fetch(auth, body));
695
1457
  case "result":
696
- return json(this.#daemon.result(auth, body));
1458
+ return json(await this.#daemon.result(auth, body));
697
1459
  case "heartbeat":
698
- return json(this.#daemon.heartbeat(auth, body));
1460
+ return json(await this.#daemon.heartbeat(auth, body));
699
1461
  case "release":
700
- return json(this.#daemon.release(auth, body));
1462
+ return json(await this.#daemon.release(auth, body));
701
1463
  default:
702
1464
  return json({ status: 404, body: { error: "not-found" } });
703
1465
  }
@@ -710,8 +1472,8 @@ function safeJson(raw) {
710
1472
  return void 0;
711
1473
  }
712
1474
  }
713
- function signatureFrom(headers) {
714
- const runnerId = headers.get("x-byollm-runner");
1475
+ function signatureFrom(headers, callerHeader) {
1476
+ const runnerId = headers.get(callerHeader);
715
1477
  const issuedAt = headers.get("x-byollm-issued-at");
716
1478
  const signature = headers.get("x-byollm-signature");
717
1479
  if (runnerId === null || issuedAt === null || signature === null) {
@@ -728,12 +1490,20 @@ export {
728
1490
  ConsentRecord,
729
1491
  DeviceRecord,
730
1492
  EMPTY_FIXTURE,
1493
+ MAX_OUTSTANDING_PAIRINGS,
1494
+ MemoryPairingCodes,
1495
+ PAIRING_BUSY_MESSAGE,
1496
+ PAIRING_CODE_TTL_MS,
731
1497
  Projection,
732
1498
  Relay,
733
1499
  RelayFixture as RelayFixtureSchema,
734
1500
  RelayState,
735
1501
  RevocationRecord,
736
1502
  RosterRecord,
737
- debugPage
1503
+ SiteRecord,
1504
+ debugPage,
1505
+ newDeviceCode,
1506
+ newUserCode,
1507
+ routeKey
738
1508
  };
739
1509
  //# sourceMappingURL=index.js.map