@byollm/server 0.1.0-alpha.2 → 0.1.0-alpha.21

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,17 +1,18 @@
1
1
  import {
2
2
  ByollmHandlers,
3
3
  SERVED_PROTOCOL_VERSION,
4
- bearerFrom,
5
4
  createFetchHandler,
6
5
  generateDeviceCode,
7
6
  generateJobId,
7
+ generateLeaseId,
8
8
  generateRunnerId,
9
- generateRunnerToken,
10
9
  generateUserCode,
11
10
  hashSecret,
11
+ resealForDevice,
12
12
  routeEndpoint,
13
- secretsMatch
14
- } from "./chunk-HL6EYHQ7.js";
13
+ secretsMatch,
14
+ signatureFrom
15
+ } from "./chunk-U4FB6ZRE.js";
15
16
  import {
16
17
  NoRunnerAvailableError,
17
18
  PollingDelivery,
@@ -20,19 +21,291 @@ import {
20
21
 
21
22
  // src/app.ts
22
23
  import {
24
+ ENVELOPE_MAX_AGE_MS,
25
+ KindedPayload,
26
+ keyId as keyId2,
27
+ payloadTextLength,
28
+ publicIdentityOf as publicIdentityOf2,
29
+ seal,
30
+ sizeClassOf,
23
31
  backendDescriptor,
24
32
  matchAudience
25
33
  } from "@byollm/protocol";
34
+
35
+ // src/cloud.ts
36
+ import {
37
+ SealedOutcome,
38
+ keyId,
39
+ open,
40
+ publicIdentityOf,
41
+ provenanceFor,
42
+ signSiteRequest
43
+ } from "@byollm/protocol";
44
+ var CloudLane = class {
45
+ #options;
46
+ #store;
47
+ #siteKeys;
48
+ #now;
49
+ #fetch;
50
+ constructor(deps) {
51
+ this.#options = deps.options;
52
+ this.#store = deps.store;
53
+ this.#siteKeys = deps.siteKeys;
54
+ this.#now = deps.now;
55
+ this.#fetch = deps.options.fetch ?? globalThis.fetch;
56
+ }
57
+ /**
58
+ * Publish a job's stub for routing.
59
+ *
60
+ * The stub and nothing else — byollm_009 §6 makes that exhaustive by
61
+ * construction, so this cannot leak a payload even by mistake: there is no
62
+ * field on `JobStub` to put one in.
63
+ */
64
+ async publish(record) {
65
+ const stub = {
66
+ id: record.id,
67
+ kind: record.kind,
68
+ owner: record.owner,
69
+ // This site, by its identity key id — Amendment A §A.3. The relay
70
+ // already knows which site it is routing for, so this discloses nothing
71
+ // new to it; what it adds is that the *daemon* can check the stub
72
+ // against the envelope's `senderKeyId` without asking the relay.
73
+ site: keyId(publicIdentityOf(this.#siteKeys).identity),
74
+ audience: record.audience,
75
+ // `audienceAllow` is deliberately **not** published — cloud_008 §0.2.
76
+ //
77
+ // It is a list of the people who may run this job, and on the direct
78
+ // plane that is unremarkable: the site authored the list and the site is
79
+ // the upstream, so the party receiving it already has it. Through a
80
+ // relay it is a third party, and byollm_009 §6's enumerated metadata —
81
+ // "exhaustive and normative… what an upstream can see, stated as a
82
+ // commitment" — does not include it. It was reaching the relay on every
83
+ // named-audience job.
84
+ //
85
+ // Nothing is lost by withholding it, which is why this is a Tier 0 fix
86
+ // rather than a trade. `matchAudience` treats it as a *narrowing*:
87
+ // `job.audienceAllow !== undefined && !includes(daemon.owner)` refuses,
88
+ // and its absence simply falls through to the checks that actually
89
+ // enforce — the daemon's own allowlist (`NAMED_LOCAL_ALLOWLIST`) and the
90
+ // backend's offer scope. On this lane the relay narrows too, from the
91
+ // control plane's rosters. The enforcement was never here.
92
+ sizeClass: record.sizeClass,
93
+ streaming: false,
94
+ // The relay needs *a* deadline to bound routing. A job without one gets
95
+ // the envelope's, which is the outer bound on how long the ciphertext
96
+ // is worth carrying — never longer than the work could possibly matter.
97
+ deadlineAt: record.deadlineAt ?? record.createdAt + ENVELOPE_TTL_FALLBACK
98
+ };
99
+ await this.#post("enqueue", {
100
+ siteId: this.#options.siteId,
101
+ stub
102
+ });
103
+ }
104
+ /**
105
+ * Withdraw a job at the relay — cloud_008 §2.2.
106
+ *
107
+ * `app.cancel()` marks the site's own row terminal, which stops the *next*
108
+ * seal. It cannot stop a device that is already running the work, because
109
+ * on this lane the site is not the upstream: only the relay talks to the
110
+ * daemon, and it answered `cancel: []` unconditionally.
111
+ *
112
+ * So the cancellation has to travel. The relay marks the job, stops
113
+ * offering it, and names it to the holding device at its next heartbeat —
114
+ * the same path the direct plane has always had, arriving one hop later.
115
+ */
116
+ async cancel(jobId) {
117
+ await this.#post("cancel", { siteId: this.#options.siteId, jobId });
118
+ }
119
+ /**
120
+ * One cycle: seal for anything claimed, collect anything finished.
121
+ *
122
+ * Idempotent and safe to call as often as you like. Exposed as a single
123
+ * cycle rather than hidden behind a timer so a caller decides its own
124
+ * cadence — a serverless site runs it on a cron, a long-lived one on an
125
+ * interval, and a test runs it exactly when it means to.
126
+ */
127
+ async pump() {
128
+ const sealed = [];
129
+ const refused = [];
130
+ const completed = [];
131
+ const pending = await this.#get("pending");
132
+ for (const claim of pending.jobs) {
133
+ const record = await this.#store.get(claim.jobId);
134
+ if (!record) continue;
135
+ const resealed = await resealForDevice({
136
+ siteKeys: this.#siteKeys,
137
+ job: {
138
+ id: record.id,
139
+ envelope: record.envelope,
140
+ createdAt: record.createdAt
141
+ },
142
+ device: claim.device
143
+ });
144
+ if (!resealed.ok) {
145
+ refused.push(claim.jobId);
146
+ continue;
147
+ }
148
+ const adopted = await this.#store.adopt({
149
+ jobId: claim.jobId,
150
+ leaseId: claim.leaseId,
151
+ expiresAt: claim.leaseExpiresAt,
152
+ now: this.#now()
153
+ });
154
+ if (!adopted) {
155
+ refused.push(claim.jobId);
156
+ continue;
157
+ }
158
+ await this.#post("payload", {
159
+ siteId: this.#options.siteId,
160
+ jobId: claim.jobId,
161
+ envelope: resealed.envelope
162
+ });
163
+ sealed.push(claim.jobId);
164
+ }
165
+ const finished = await this.#get("results");
166
+ for (const done of finished.jobs) {
167
+ const record = await this.#store.get(done.jobId);
168
+ if (!record || record.state === "ok" || record.state === "error") {
169
+ continue;
170
+ }
171
+ const outcome = await this.#openResult(done);
172
+ if (!outcome) {
173
+ refused.push(done.jobId);
174
+ continue;
175
+ }
176
+ await this.#store.complete({
177
+ jobId: done.jobId,
178
+ // The relay named the device; the signature above proved it — §3.6.
179
+ runnerId: done.runnerId,
180
+ // The grant, not the machine: this site never paired with the device
181
+ // that ran it, and the signature it verified above is the stronger
182
+ // claim about who did.
183
+ holder: { by: "lease", leaseId: done.leaseId },
184
+ outcome: outcome.outcome,
185
+ provenance: provenanceFor({
186
+ audience: record.audience,
187
+ runnerId: done.runnerId,
188
+ // The owner, from the relay's own record of who claimed it — not a
189
+ // key id. cloud_008 §2.5: this said `keyId(device.identity)`, which
190
+ // put a key id where the direct plane puts a user id, so an app
191
+ // comparing provenance across lanes compared two namespaces and got
192
+ // `false` for the same person. The device's key is still what the
193
+ // signature was verified against, above; that is a different
194
+ // question from whose machine it is.
195
+ runnerOwner: done.runnerOwner,
196
+ // From the envelope, not invented — cloud_008 §2.5. These were
197
+ // hardcoded `"http"` and `"unknown"` because the daemon's declared
198
+ // values stopped at the relay, which is right: a blind relay acts
199
+ // on neither. Sealing them carries them past it untouched.
200
+ backendClass: outcome.ran.backendClass,
201
+ model: outcome.ran.model
202
+ }),
203
+ now: this.#now()
204
+ });
205
+ completed.push(done.jobId);
206
+ }
207
+ return { sealed, completed, refused };
208
+ }
209
+ /**
210
+ * Open a sealed result and verify it came from the device that claimed it.
211
+ *
212
+ * The relay says which device ran the job; this checks that claim against a
213
+ * signature the relay cannot produce. A relay that named the wrong device
214
+ * gets a refusal, not a stored result — which is what keeps `RELAY_BLIND`
215
+ * from quietly becoming `RELAY_TRUSTED`.
216
+ */
217
+ async #openResult(done) {
218
+ const opened = await open({
219
+ envelope: done.envelope,
220
+ recipientKeys: this.#siteKeys,
221
+ senderIdentityPublic: done.device.identity,
222
+ expected: {
223
+ jobId: done.jobId,
224
+ senderKeyId: keyId(done.device.identity),
225
+ recipientKeyId: keyId(publicIdentityOf(this.#siteKeys).identity),
226
+ direction: "result"
227
+ }
228
+ });
229
+ if (!opened.ok) return null;
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(opened.plaintext);
233
+ } catch {
234
+ return null;
235
+ }
236
+ const sealed = SealedOutcome.safeParse(parsed);
237
+ if (!sealed.success) return null;
238
+ if (sealed.data.outcome.outcome !== done.disposition) return null;
239
+ return sealed.data;
240
+ }
241
+ /**
242
+ * Sign a site-plane call with this site's identity key.
243
+ *
244
+ * The same scheme the daemon uses against an upstream, because the site is
245
+ * in the same position: an outbound caller whose key the relay already holds
246
+ * for other reasons. Nothing else authenticates this plane — a relay that
247
+ * took the `siteId` in a body at face value would let anyone enqueue work in
248
+ * a site's name and read who claimed it.
249
+ */
250
+ #headers(endpoint, rawBody) {
251
+ const signature = signSiteRequest(this.#siteKeys, {
252
+ endpoint,
253
+ siteId: this.#options.siteId,
254
+ issuedAt: this.#now(),
255
+ body: rawBody
256
+ });
257
+ return {
258
+ "x-byollm-site": this.#options.siteId,
259
+ "x-byollm-issued-at": String(signature.issuedAt),
260
+ "x-byollm-signature": signature.signature
261
+ };
262
+ }
263
+ async #post(endpoint, body) {
264
+ const rawBody = JSON.stringify(body);
265
+ const response = await this.#fetch(
266
+ `${this.#options.relayOrigin}/relay/site/${endpoint}`,
267
+ {
268
+ method: "POST",
269
+ headers: {
270
+ "content-type": "application/json",
271
+ ...this.#headers(endpoint, rawBody)
272
+ },
273
+ body: rawBody
274
+ }
275
+ );
276
+ return response.json();
277
+ }
278
+ async #get(endpoint) {
279
+ const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}`;
280
+ const response = await this.#fetch(url, {
281
+ headers: this.#headers(endpoint, "")
282
+ });
283
+ return response.json();
284
+ }
285
+ };
286
+ var ENVELOPE_TTL_FALLBACK = 24 * 60 * 6e4;
287
+
288
+ // src/app.ts
26
289
  var DEFAULT_LIVENESS_MS = 35e3;
27
290
  var ByollmApp = class {
28
291
  #store;
292
+ #siteKeys;
29
293
  #now;
30
294
  #livenessMs;
31
295
  #delivery;
296
+ /** Present only in the cloud lane; the site's side of the relay. */
297
+ cloud;
32
298
  constructor(options) {
33
299
  this.#store = options.store;
300
+ this.#siteKeys = options.siteKeys;
34
301
  this.#now = options.now ?? Date.now;
35
302
  this.#livenessMs = options.livenessMs ?? DEFAULT_LIVENESS_MS;
303
+ this.cloud = options.lane === void 0 ? void 0 : new CloudLane({
304
+ options: options.lane,
305
+ store: options.store,
306
+ siteKeys: options.siteKeys,
307
+ now: this.#now
308
+ });
36
309
  const deps = {
37
310
  ...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
38
311
  read: (jobId) => this.result(jobId),
@@ -66,7 +339,45 @@ var ByollmApp = class {
66
339
  * the app is obliged to disclose that to whoever reads it.
67
340
  */
68
341
  async enqueue(input) {
69
- const record = await this.#store.create(input, this.#now());
342
+ const parsed = KindedPayload.safeParse({
343
+ kind: input.kind,
344
+ payload: input.payload
345
+ });
346
+ if (!parsed.success) {
347
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
348
+ throw new Error(`invalid ${input.kind} payload \u2014 ${detail}`);
349
+ }
350
+ const createdAt = this.#now();
351
+ const envelopeDeadlineAt = createdAt + ENVELOPE_MAX_AGE_MS;
352
+ const jobId = input.id ?? generateJobId();
353
+ const senderKeyId = keyId2(publicIdentityOf2(this.#siteKeys).identity);
354
+ const envelope = await seal({
355
+ plaintext: JSON.stringify(parsed.data.payload),
356
+ senderKeys: this.#siteKeys,
357
+ recipientEncryptionPublic: this.#siteKeys.encryptionPublic,
358
+ context: {
359
+ jobId,
360
+ senderKeyId,
361
+ recipientKeyId: senderKeyId,
362
+ deadlineAt: envelopeDeadlineAt,
363
+ direction: "payload"
364
+ }
365
+ });
366
+ const record = await this.#store.create(
367
+ {
368
+ ...input,
369
+ id: jobId,
370
+ envelope,
371
+ sizeClass: sizeClassOf(
372
+ payloadTextLength({
373
+ kind: input.kind,
374
+ payload: parsed.data.payload
375
+ })
376
+ )
377
+ },
378
+ createdAt
379
+ );
380
+ await this.cloud?.publish(record);
70
381
  return {
71
382
  id: record.id,
72
383
  record,
@@ -87,7 +398,7 @@ var ByollmApp = class {
87
398
  * Check `provenance.untrusted` before rendering. It is true for every
88
399
  * `named`/`public` job, because that text came from someone else's machine
89
400
  * and the app must not present it as its own AI's answer
90
- * ({@link MUSTS.RESULT_PROVENANCE}).
401
+ * ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
91
402
  */
92
403
  async result(jobId) {
93
404
  const job = await this.job(jobId);
@@ -101,7 +412,11 @@ var ByollmApp = class {
101
412
  }
102
413
  /** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
103
414
  async cancel(jobId) {
104
- return this.#store.cancel(jobId, this.#now());
415
+ const cancelled = await this.#store.cancel(jobId, this.#now());
416
+ if (cancelled && this.cloud) {
417
+ await this.cloud.cancel(jobId).catch(() => void 0);
418
+ }
419
+ return cancelled;
105
420
  }
106
421
  /**
107
422
  * Is there a live runner that could take a job of this shape?
@@ -136,7 +451,18 @@ var ByollmApp = class {
136
451
  {
137
452
  owner: runner.owner,
138
453
  offerScope: capability.offerScope,
139
- account: backendDescriptor(capability.backendId).account,
454
+ // A generic backend's cost depends on its base URL, which the
455
+ // server never sees; assume the expensive reading (byollm_007 §4).
456
+ cost: backendDescriptor(capability.backendId).cost ?? "metered",
457
+ // Consent is the daemon's to hold, and it has already applied it:
458
+ // the offer scope arriving here is the *effective* one, so a
459
+ // metered backend nobody agreed to share advertises `self` and is
460
+ // refused by the scope rule above. Re-deriving consent from
461
+ // `false` here would instead refuse every backend an owner
462
+ // deliberately shared, because the server has no way to learn they
463
+ // did — the signal would be wrong in the direction that breaks
464
+ // working setups.
465
+ spend: { acknowledged: true },
140
466
  // Same conservative assumption the claim path makes: the server
141
467
  // cannot see a remote daemon's local allowlist (protocol §4.2).
142
468
  locallyAllows: () => true
@@ -168,13 +494,10 @@ var ByollmApp = class {
168
494
  * ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
169
495
  */
170
496
  async approvePairing(args) {
171
- const token = generateRunnerToken();
172
497
  return this.#store.approvePairing({
173
498
  userCode: normalizeUserCode(args.userCode),
174
499
  owner: args.owner,
175
500
  runnerId: generateRunnerId(),
176
- runnerToken: token,
177
- tokenHash: hashSecret(token),
178
501
  now: this.#now()
179
502
  });
180
503
  }
@@ -218,6 +541,45 @@ function normalizeUserCode(input) {
218
541
  return bare.length === 8 ? `${bare.slice(0, 4)}-${bare.slice(4)}` : bare;
219
542
  }
220
543
 
544
+ // src/keys.ts
545
+ import { StoredKeys, generateKeys, publicIdentityOf as publicIdentityOf3 } from "@byollm/protocol";
546
+ import { fingerprint } from "@byollm/protocol";
547
+ var generateSiteKeys = (now = Date.now()) => generateKeys(now);
548
+ function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
549
+ const raw = env[variable];
550
+ if (raw === void 0 || raw === "") {
551
+ throw new Error(
552
+ `${variable} is not set. Generate a site identity once with \`npx @byollm/server keygen\` and set it as ${variable}. Do not generate keys at startup: every instance would get a different identity and daemons would pin one and be refused by another.`
553
+ );
554
+ }
555
+ let parsed;
556
+ try {
557
+ parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
558
+ } catch {
559
+ throw new Error(
560
+ `${variable} is not base64-encoded JSON. It should be exactly what \`npx @byollm/server keygen\` printed.`
561
+ );
562
+ }
563
+ const result = StoredKeys.safeParse(parsed);
564
+ if (!result.success) {
565
+ throw new Error(
566
+ `${variable} does not contain a valid site identity. Regenerate it with \`npx @byollm/server keygen\` \u2014 and if this site has already paired daemons, they will need to pair again.`
567
+ );
568
+ }
569
+ return result.data;
570
+ }
571
+ function formatSiteKeys(keys) {
572
+ const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
573
+ return `# Set this as BYOLLM_SITE_KEYS. It is a secret: it is this site's
574
+ # identity, and anything holding it can be this site.
575
+ BYOLLM_SITE_KEYS=${encoded}
576
+
577
+ # Fingerprint (not secret \u2014 show it to users so they can check what
578
+ # their daemon pinned):
579
+ # ${fingerprint(publicIdentityOf3(keys).identity)}
580
+ `;
581
+ }
582
+
221
583
  // src/memory.ts
222
584
  import {
223
585
  backendDescriptor as backendDescriptor2,
@@ -236,7 +598,7 @@ var MemoryStore = class {
236
598
  }
237
599
  // -- jobs ---------------------------------------------------------------
238
600
  create(input, now) {
239
- const id = input.id ?? generateJobId();
601
+ const id = input.id;
240
602
  const existing = this.#jobs.get(id);
241
603
  if (existing) return Promise.resolve(existing);
242
604
  const dependsOn = [...input.dependsOn ?? []];
@@ -246,13 +608,15 @@ var MemoryStore = class {
246
608
  const job = {
247
609
  id,
248
610
  kind: input.kind,
249
- payload: input.payload,
611
+ envelope: input.envelope,
612
+ sizeClass: input.sizeClass,
250
613
  audience: input.audience ?? "self",
251
614
  owner: input.owner,
252
615
  audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
253
616
  dependsOn,
254
617
  state: "queued",
255
618
  lease: null,
619
+ completedByLeaseId: null,
256
620
  createdAt: now,
257
621
  // The TTL clock starts here only if nothing blocks the job.
258
622
  claimableAt: blocked ? null : now,
@@ -264,7 +628,7 @@ var MemoryStore = class {
264
628
  provenance: null,
265
629
  updatedAt: now
266
630
  };
267
- this.#jobs.set(id, job);
631
+ this.#write(id, job);
268
632
  return Promise.resolve(job);
269
633
  }
270
634
  get(jobId) {
@@ -283,13 +647,16 @@ var MemoryStore = class {
283
647
  ...job,
284
648
  state: "claimed",
285
649
  lease: {
650
+ // A fresh id per grant. Two claims of the same job by the same
651
+ // runner are two different leases, and must be distinguishable.
652
+ id: generateLeaseId(),
286
653
  runnerId: args.runnerId,
287
654
  expiresAt: args.now + args.leaseMs
288
655
  },
289
656
  attempts: job.attempts + 1,
290
657
  updatedAt: args.now
291
658
  };
292
- this.#jobs.set(job.id, updated);
659
+ this.#write(job.id, updated);
293
660
  claimed.push(updated);
294
661
  }
295
662
  return Promise.resolve(claimed);
@@ -313,9 +680,16 @@ var MemoryStore = class {
313
680
  {
314
681
  owner: args.runnerOwner,
315
682
  offerScope: capability.offerScope,
316
- // From the registry, not a local guess — the subscription self-lock
317
- // must mean the same thing on both sides of the wire.
318
- account: backendDescriptor2(capability.backendId).account,
683
+ // From the registry, not a local guess — the cost rules must mean the
684
+ // same thing on both sides of the wire. The server cannot see a
685
+ // remote daemon's base URL, so a generic backend with no declared
686
+ // cost is treated as metered: the expensive side, and the daemon
687
+ // refuses anyway if it disagrees (byollm_007 §2).
688
+ cost: backendDescriptor2(capability.backendId).cost ?? "metered",
689
+ // Nor can it see the owner's spend consent. It offers; the daemon is
690
+ // the enforcing side and releases with `refused` if its own rules say
691
+ // no — the same shape as the `named` allowlist.
692
+ spend: { acknowledged: true },
319
693
  // The server cannot see a remote daemon's local allowlist and must
320
694
  // not pretend to (protocol §4.2). It admits the job here; the daemon
321
695
  // is the enforcing side and releases with `refused` if its own list
@@ -329,9 +703,9 @@ var MemoryStore = class {
329
703
  this.#expireDueSync(args.now);
330
704
  const renewed = [];
331
705
  const lost = [];
332
- for (const jobId of args.jobIds) {
706
+ for (const { jobId, leaseId } of args.leases) {
333
707
  const job = this.#jobs.get(jobId);
334
- if (!job || job.lease?.runnerId !== args.runnerId) {
708
+ if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
335
709
  lost.push(jobId);
336
710
  continue;
337
711
  }
@@ -340,26 +714,56 @@ var MemoryStore = class {
340
714
  continue;
341
715
  }
342
716
  const expiresAt = args.now + args.leaseMs;
343
- this.#jobs.set(jobId, {
717
+ this.#write(jobId, {
344
718
  ...job,
345
719
  state: "running",
346
- lease: { runnerId: args.runnerId, expiresAt },
720
+ // Renewal extends the existing grant; it does not mint a new one.
721
+ lease: { ...job.lease, expiresAt },
347
722
  updatedAt: args.now
348
723
  });
349
724
  renewed.push({ jobId, expiresAt });
350
725
  }
351
726
  return Promise.resolve({ renewed, lost });
352
727
  }
728
+ adopt(args) {
729
+ const job = this.#jobs.get(args.jobId);
730
+ if (!job) return Promise.resolve(null);
731
+ if (job.state !== "queued" && job.state !== "claimed") {
732
+ return Promise.resolve(null);
733
+ }
734
+ if (job.lease && job.lease.id !== args.leaseId) {
735
+ return Promise.resolve(null);
736
+ }
737
+ const updated = {
738
+ ...job,
739
+ state: "claimed",
740
+ lease: {
741
+ id: args.leaseId,
742
+ // No runner: this site never paired with the machine holding it.
743
+ runnerId: "",
744
+ expiresAt: args.expiresAt
745
+ },
746
+ updatedAt: args.now
747
+ };
748
+ this.#write(updated.id, updated);
749
+ return Promise.resolve(updated);
750
+ }
353
751
  complete(args) {
354
752
  const job = this.#jobs.get(args.jobId);
355
753
  if (!job) return Promise.resolve({ accepted: false, job: null });
356
754
  if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
755
+ const sameDevice = job.provenance?.runnerId !== void 0 && job.provenance.runnerId === args.runnerId;
756
+ const sameGrant = args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
757
+ if (sameDevice && sameGrant) {
758
+ return Promise.resolve({ accepted: false, duplicate: true, job });
759
+ }
357
760
  return Promise.resolve({ accepted: false, job });
358
761
  }
359
762
  if (job.state === "expired") {
360
763
  return Promise.resolve({ accepted: false, job });
361
764
  }
362
- if (job.lease?.runnerId !== args.runnerId) {
765
+ const holds = args.holder.by === "runner" ? job.lease?.runnerId === args.holder.runnerId : job.lease?.id === args.holder.leaseId;
766
+ if (!holds) {
363
767
  return Promise.resolve({ accepted: false, job });
364
768
  }
365
769
  const state = args.outcome.outcome === "ok" ? "ok" : args.outcome.outcome === "canceled" ? "canceled" : "error";
@@ -367,11 +771,13 @@ var MemoryStore = class {
367
771
  ...job,
368
772
  state,
369
773
  lease: null,
774
+ // The grant that recorded it, kept after the lease is dropped — §3.6.
775
+ completedByLeaseId: args.holder.by === "lease" ? args.holder.leaseId : job.lease?.id ?? null,
370
776
  outcome: args.outcome,
371
777
  provenance: args.provenance,
372
778
  updatedAt: args.now
373
779
  };
374
- this.#jobs.set(job.id, updated);
780
+ this.#write(job.id, updated);
375
781
  this.#cancelRequests.delete(job.id);
376
782
  if (state === "ok") this.#unblockDependents(job.id, args.now);
377
783
  return Promise.resolve({ accepted: true, job: updated });
@@ -392,19 +798,70 @@ var MemoryStore = class {
392
798
  (depId) => this.#jobs.get(depId)?.state === "ok"
393
799
  );
394
800
  if (ready) {
395
- this.#jobs.set(job.id, { ...job, claimableAt: now, updatedAt: now });
801
+ this.#write(job.id, { ...job, claimableAt: now, updatedAt: now });
802
+ }
803
+ }
804
+ }
805
+ /**
806
+ * Watchers, by job id (byollm_009 §8.3).
807
+ *
808
+ * A `Set` per job so an unsubscribe removes exactly the handler it
809
+ * registered — two waiters on the same job are ordinary, and removing by
810
+ * job id alone would silently cancel someone else's wait.
811
+ */
812
+ #watchers = /* @__PURE__ */ new Map();
813
+ subscribe(jobId, onChange) {
814
+ const existing = this.#watchers.get(jobId) ?? /* @__PURE__ */ new Set();
815
+ existing.add(onChange);
816
+ this.#watchers.set(jobId, existing);
817
+ let live = true;
818
+ return () => {
819
+ if (!live) return;
820
+ live = false;
821
+ const set = this.#watchers.get(jobId);
822
+ set?.delete(onChange);
823
+ if (set?.size === 0) this.#watchers.delete(jobId);
824
+ };
825
+ }
826
+ /**
827
+ * The single write path for a job.
828
+ *
829
+ * Every mutation goes through here so notification cannot be forgotten by
830
+ * a future one. Nine call sites existed when the push seam was added, and
831
+ * "remember to notify" is not a property nine call sites keep.
832
+ */
833
+ #write(jobId, record) {
834
+ this.#jobs.set(jobId, record);
835
+ this.#notify(jobId);
836
+ }
837
+ /**
838
+ * Tell anyone watching that a job changed.
839
+ *
840
+ * A throwing watcher must not corrupt the store's own bookkeeping, so each
841
+ * is isolated: this runs inside write paths, and one bad listener taking
842
+ * out an unrelated write would be a far worse failure than a missed
843
+ * notification.
844
+ */
845
+ #notify(jobId) {
846
+ for (const watcher of this.#watchers.get(jobId) ?? []) {
847
+ try {
848
+ watcher();
849
+ } catch {
396
850
  }
397
851
  }
398
852
  }
399
853
  release(args) {
400
854
  const released = [];
401
- for (const jobId of args.jobIds) {
855
+ for (const { jobId, leaseId } of args.leases) {
402
856
  const job = this.#jobs.get(jobId);
403
- if (!job || job.lease?.runnerId !== args.runnerId) continue;
404
- this.#jobs.set(jobId, {
857
+ if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
858
+ continue;
859
+ }
860
+ this.#write(jobId, {
405
861
  ...job,
406
862
  state: "queued",
407
863
  lease: null,
864
+ completedByLeaseId: null,
408
865
  // Newly available again, so the TTL clock restarts here too.
409
866
  claimableAt: args.now,
410
867
  // A refusal is remembered, or the pair spins between claim and
@@ -434,6 +891,7 @@ var MemoryStore = class {
434
891
  ...job,
435
892
  state: "queued",
436
893
  lease: null,
894
+ completedByLeaseId: null,
437
895
  // The TTL clock restarts: it measures how long a job has waited
438
896
  // *unclaimed*, and this job has just become available again. Without
439
897
  // this, a job whose runner died would expire for time it spent being
@@ -443,7 +901,7 @@ var MemoryStore = class {
443
901
  claimableAt: now,
444
902
  updatedAt: now
445
903
  };
446
- this.#jobs.set(job.id, requeued);
904
+ this.#write(job.id, requeued);
447
905
  changed.push(requeued);
448
906
  }
449
907
  }
@@ -456,9 +914,10 @@ var MemoryStore = class {
456
914
  ...job,
457
915
  state: "expired",
458
916
  lease: null,
917
+ completedByLeaseId: null,
459
918
  updatedAt: now
460
919
  };
461
- this.#jobs.set(job.id, expired);
920
+ this.#write(job.id, expired);
462
921
  changed.push(expired);
463
922
  }
464
923
  return changed;
@@ -471,9 +930,10 @@ var MemoryStore = class {
471
930
  ...job,
472
931
  state: "canceled",
473
932
  lease: null,
933
+ completedByLeaseId: null,
474
934
  updatedAt: now
475
935
  };
476
- this.#jobs.set(jobId, canceled);
936
+ this.#write(jobId, canceled);
477
937
  return Promise.resolve(canceled);
478
938
  }
479
939
  if (job.state === "claimed" || job.state === "running") {
@@ -524,7 +984,9 @@ var MemoryStore = class {
524
984
  const runner = {
525
985
  id: args.runnerId,
526
986
  owner: args.owner,
527
- tokenHash: args.tokenHash,
987
+ // Carried from the pairing, not re-supplied at approval: the user
988
+ // approved a specific machine, and the runner must be that machine.
989
+ device: pairing.device,
528
990
  label: pairing.label,
529
991
  platform: pairing.platform,
530
992
  daemonVersion: pairing.daemonVersion,
@@ -540,7 +1002,7 @@ var MemoryStore = class {
540
1002
  state: "approved",
541
1003
  owner: args.owner,
542
1004
  runnerId: runner.id,
543
- runnerTokenOnce: args.runnerToken
1005
+ collected: false
544
1006
  });
545
1007
  return Promise.resolve(runner);
546
1008
  }
@@ -561,17 +1023,11 @@ var MemoryStore = class {
561
1023
  if (pairing) {
562
1024
  this.#pairings.set(deviceCodeHash, {
563
1025
  ...pairing,
564
- runnerTokenOnce: null
1026
+ collected: true
565
1027
  });
566
1028
  }
567
1029
  return Promise.resolve();
568
1030
  }
569
- getRunnerByTokenHash(hash) {
570
- for (const runner of this.#runners.values()) {
571
- if (runner.tokenHash === hash) return Promise.resolve(runner);
572
- }
573
- return Promise.resolve(null);
574
- }
575
1031
  getRunner(runnerId) {
576
1032
  return Promise.resolve(this.#runners.get(runnerId) ?? null);
577
1033
  }
@@ -613,22 +1069,25 @@ function capabilityFor(capabilities, kind) {
613
1069
  export {
614
1070
  ByollmApp,
615
1071
  ByollmHandlers,
1072
+ CloudLane,
616
1073
  MemoryStore,
617
1074
  NoRunnerAvailableError,
618
1075
  PollingDelivery,
619
1076
  ResultTimeoutError,
620
1077
  SERVED_PROTOCOL_VERSION,
621
- bearerFrom,
622
1078
  capabilityFor,
623
1079
  createFetchHandler,
1080
+ formatSiteKeys,
624
1081
  generateDeviceCode,
625
1082
  generateJobId,
626
1083
  generateRunnerId,
627
- generateRunnerToken,
1084
+ generateSiteKeys,
628
1085
  generateUserCode,
629
1086
  hashSecret,
630
1087
  normalizeUserCode,
631
1088
  routeEndpoint,
632
- secretsMatch
1089
+ secretsMatch,
1090
+ signatureFrom,
1091
+ siteKeysFromEnv
633
1092
  };
634
1093
  //# sourceMappingURL=index.js.map