@byollm/server 0.1.0-alpha.7 → 0.1.0-alpha.71

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
@@ -2,23 +2,23 @@ import {
2
2
  ByollmHandlers,
3
3
  SERVED_PROTOCOL_VERSION,
4
4
  createFetchHandler,
5
+ deadlineFor,
5
6
  generateDeviceCode,
6
7
  generateJobId,
7
8
  generateLeaseId,
8
9
  generateRunnerId,
9
- generateRunnerToken,
10
10
  generateUserCode,
11
11
  hashSecret,
12
12
  resealForDevice,
13
13
  routeEndpoint,
14
14
  secretsMatch,
15
15
  signatureFrom
16
- } from "./chunk-4NIHWQAT.js";
16
+ } from "./chunk-DG6XZQU3.js";
17
17
  import {
18
18
  NoRunnerAvailableError,
19
19
  PollingDelivery,
20
20
  ResultTimeoutError
21
- } from "./chunk-7RKXFPBZ.js";
21
+ } from "./chunk-I3ER27QG.js";
22
22
 
23
23
  // src/app.ts
24
24
  import {
@@ -35,12 +35,36 @@ import {
35
35
 
36
36
  // src/cloud.ts
37
37
  import {
38
- JobOutcome,
38
+ PROTOCOL_VERSION,
39
+ SealedOutcome,
39
40
  keyId,
40
41
  open,
41
42
  publicIdentityOf,
42
- provenanceFor
43
+ provenanceFor,
44
+ signSiteRequest
43
45
  } from "@byollm/protocol";
46
+ var RelayUnavailable = class extends Error {
47
+ retryable;
48
+ /** The protocol's own code, when the relay sent one. */
49
+ code;
50
+ constructor(message, retryable, code) {
51
+ super(message);
52
+ this.name = "RelayUnavailable";
53
+ this.retryable = retryable;
54
+ this.code = code;
55
+ }
56
+ };
57
+ var EnqueueRefused = class extends Error {
58
+ /** `purpose-not-declared` or `slot-unsatisfiable`. */
59
+ code;
60
+ constructor(message, code) {
61
+ super(message);
62
+ this.name = "EnqueueRefused";
63
+ this.code = code;
64
+ }
65
+ };
66
+ var RETRYABLE_AT_ENQUEUE = /* @__PURE__ */ new Set(["not-ready"]);
67
+ var ENQUEUE_ENDPOINT = "enqueue";
44
68
  var CloudLane = class {
45
69
  #options;
46
70
  #store;
@@ -66,20 +90,64 @@ var CloudLane = class {
66
90
  id: record.id,
67
91
  kind: record.kind,
68
92
  owner: record.owner,
93
+ // This site, by its identity key id — Amendment A §A.3. The relay
94
+ // already knows which site it is routing for, so this discloses nothing
95
+ // new to it; what it adds is that the *daemon* can check the stub
96
+ // against the envelope's `senderKeyId` without asking the relay.
97
+ site: keyId(publicIdentityOf(this.#siteKeys).identity),
69
98
  audience: record.audience,
70
- ...record.audienceAllow === void 0 ? {} : { audienceAllow: [...record.audienceAllow] },
99
+ // `audienceAllow` is deliberately **not** published cloud_008 §0.2.
100
+ //
101
+ // It is a list of the people who may run this job, and on the direct
102
+ // plane that is unremarkable: the site authored the list and the site is
103
+ // the upstream, so the party receiving it already has it. Through a
104
+ // relay it is a third party, and byollm_009 §6's enumerated metadata —
105
+ // "exhaustive and normative… what an upstream can see, stated as a
106
+ // commitment" — does not include it. It was reaching the relay on every
107
+ // named-audience job.
108
+ //
109
+ // Nothing is lost by withholding it, which is why this is a Tier 0 fix
110
+ // rather than a trade. `matchAudience` treats it as a *narrowing*:
111
+ // `job.audienceAllow !== undefined && !includes(daemon.owner)` refuses,
112
+ // and its absence simply falls through to the checks that actually
113
+ // enforce — the daemon's own allowlist (`NAMED_LOCAL_ALLOWLIST`) and the
114
+ // backend's offer scope. On this lane the relay narrows too, from the
115
+ // control plane's rosters. The enforcement was never here.
116
+ ...record.purpose === void 0 ? {} : { purpose: record.purpose },
71
117
  sizeClass: record.sizeClass,
72
118
  streaming: false,
73
119
  // The relay needs *a* deadline to bound routing. A job without one gets
74
120
  // the envelope's, which is the outer bound on how long the ciphertext
75
121
  // is worth carrying — never longer than the work could possibly matter.
76
- deadlineAt: record.deadlineAt ?? record.createdAt + ENVELOPE_TTL_FALLBACK
122
+ // The same fallback the direct plane uses — cloud_008 Tier 4, finding
123
+ // 31. This said `createdAt + ENVELOPE_TTL_FALLBACK`, a local constant
124
+ // whose value happened to equal `ENVELOPE_MAX_AGE_MS`; the direct plane
125
+ // said `(claimableAt ?? now) + ttlMs`. One field, two meanings, and a
126
+ // job that was blocked on a dependency got a deadline measured from
127
+ // when it was *created* on one lane and from when it became *claimable*
128
+ // on the other.
129
+ deadlineAt: deadlineFor(record, this.#now())
77
130
  };
78
- await this.#post("/relay/site/enqueue", {
131
+ await this.#post("enqueue", {
79
132
  siteId: this.#options.siteId,
80
133
  stub
81
134
  });
82
135
  }
136
+ /**
137
+ * Withdraw a job at the relay — cloud_008 §2.2.
138
+ *
139
+ * `app.cancel()` marks the site's own row terminal, which stops the *next*
140
+ * seal. It cannot stop a device that is already running the work, because
141
+ * on this lane the site is not the upstream: only the relay talks to the
142
+ * daemon, and it answered `cancel: []` unconditionally.
143
+ *
144
+ * So the cancellation has to travel. The relay marks the job, stops
145
+ * offering it, and names it to the holding device at its next heartbeat —
146
+ * the same path the direct plane has always had, arriving one hop later.
147
+ */
148
+ async cancel(jobId) {
149
+ await this.#post("cancel", { siteId: this.#options.siteId, jobId });
150
+ }
83
151
  /**
84
152
  * One cycle: seal for anything claimed, collect anything finished.
85
153
  *
@@ -92,7 +160,17 @@ var CloudLane = class {
92
160
  const sealed = [];
93
161
  const refused = [];
94
162
  const completed = [];
95
- const pending = await this.#get("/relay/site/pending");
163
+ try {
164
+ return await this.#cycle(sealed, refused, completed);
165
+ } catch (error) {
166
+ if (error instanceof RelayUnavailable && error.retryable) {
167
+ return { sealed, completed, refused, deferred: error.message };
168
+ }
169
+ throw error;
170
+ }
171
+ }
172
+ async #cycle(sealed, refused, completed) {
173
+ const pending = await this.#get("pending");
96
174
  for (const claim of pending.jobs) {
97
175
  const record = await this.#store.get(claim.jobId);
98
176
  if (!record) continue;
@@ -109,20 +187,24 @@ var CloudLane = class {
109
187
  refused.push(claim.jobId);
110
188
  continue;
111
189
  }
112
- await this.#store.adopt({
190
+ const adopted = await this.#store.adopt({
113
191
  jobId: claim.jobId,
114
192
  leaseId: claim.leaseId,
115
- expiresAt: claim.awaitingUntil,
193
+ expiresAt: claim.leaseExpiresAt,
116
194
  now: this.#now()
117
195
  });
118
- await this.#post("/relay/site/payload", {
196
+ if (!adopted) {
197
+ refused.push(claim.jobId);
198
+ continue;
199
+ }
200
+ await this.#post("payload", {
119
201
  siteId: this.#options.siteId,
120
202
  jobId: claim.jobId,
121
203
  envelope: resealed.envelope
122
204
  });
123
205
  sealed.push(claim.jobId);
124
206
  }
125
- const finished = await this.#get("/relay/site/results");
207
+ const finished = await this.#get("results");
126
208
  for (const done of finished.jobs) {
127
209
  const record = await this.#store.get(done.jobId);
128
210
  if (!record || record.state === "ok" || record.state === "error") {
@@ -135,17 +217,30 @@ var CloudLane = class {
135
217
  }
136
218
  await this.#store.complete({
137
219
  jobId: done.jobId,
220
+ // The relay named the device; the signature above proved it — §3.6.
221
+ runnerId: done.runnerId,
138
222
  // The grant, not the machine: this site never paired with the device
139
223
  // that ran it, and the signature it verified above is the stronger
140
224
  // claim about who did.
141
225
  holder: { by: "lease", leaseId: done.leaseId },
142
- outcome,
226
+ outcome: outcome.outcome,
143
227
  provenance: provenanceFor({
144
228
  audience: record.audience,
145
229
  runnerId: done.runnerId,
146
- runnerOwner: keyId(done.device.identity),
147
- backendClass: "http",
148
- model: "unknown"
230
+ // The owner, from the relay's own record of who claimed it — not a
231
+ // key id. cloud_008 §2.5: this said `keyId(device.identity)`, which
232
+ // put a key id where the direct plane puts a user id, so an app
233
+ // comparing provenance across lanes compared two namespaces and got
234
+ // `false` for the same person. The device's key is still what the
235
+ // signature was verified against, above; that is a different
236
+ // question from whose machine it is.
237
+ runnerOwner: done.runnerOwner,
238
+ // From the envelope, not invented — cloud_008 §2.5. These were
239
+ // hardcoded `"http"` and `"unknown"` because the daemon's declared
240
+ // values stopped at the relay, which is right: a blind relay acts
241
+ // on neither. Sealing them carries them past it untouched.
242
+ backendClass: outcome.ran.backendClass,
243
+ model: outcome.ran.model
149
244
  }),
150
245
  now: this.#now()
151
246
  });
@@ -180,29 +275,110 @@ var CloudLane = class {
180
275
  } catch {
181
276
  return null;
182
277
  }
183
- const outcome = JobOutcome.safeParse(parsed);
184
- if (!outcome.success) return null;
185
- if (outcome.data.outcome !== done.disposition) return null;
186
- return outcome.data;
278
+ const sealed = SealedOutcome.safeParse(parsed);
279
+ if (!sealed.success) return null;
280
+ if (sealed.data.outcome.outcome !== done.disposition) return null;
281
+ return sealed.data;
282
+ }
283
+ /**
284
+ * Sign a site-plane call with this site's identity key.
285
+ *
286
+ * The same scheme the daemon uses against an upstream, because the site is
287
+ * in the same position: an outbound caller whose key the relay already holds
288
+ * for other reasons. Nothing else authenticates this plane — a relay that
289
+ * took the `siteId` in a body at face value would let anyone enqueue work in
290
+ * a site's name and read who claimed it.
291
+ */
292
+ #headers(endpoint, rawBody) {
293
+ const signature = signSiteRequest(this.#siteKeys, {
294
+ endpoint,
295
+ siteId: this.#options.siteId,
296
+ issuedAt: this.#now(),
297
+ body: rawBody
298
+ });
299
+ return {
300
+ "x-byollm-site": this.#options.siteId,
301
+ "x-byollm-issued-at": String(signature.issuedAt),
302
+ "x-byollm-signature": signature.signature
303
+ };
304
+ }
305
+ /**
306
+ * A relay answer, checked before it is believed — alpha.31.
307
+ *
308
+ * The bug this closes is one line long and its shape is general: a response
309
+ * body used without looking at the status. The daemon's client has always
310
+ * done this properly (`client.ts` maps every status to a typed refusal); the
311
+ * site's lane parsed JSON and hoped.
312
+ *
313
+ * Two classes, because they need opposite handling. **Retryable** — 503 from
314
+ * a draining pod, 429, 5xx, and the protocol's own `not-ready` — means the
315
+ * work is still there and this cycle should end quietly. **Refused** — a bad
316
+ * signature, an unknown site, a version this relay does not speak — will
317
+ * still be true in five seconds, and swallowing it would leave a site
318
+ * silently disconnected from its own users.
319
+ */
320
+ async #answer(response, endpoint) {
321
+ if (response.ok) return response.json();
322
+ let code = "";
323
+ let message;
324
+ try {
325
+ const body = await response.json();
326
+ code = body.error ?? "";
327
+ message = body.message ?? "";
328
+ } catch {
329
+ message = `HTTP ${String(response.status)}`;
330
+ }
331
+ const retryable = response.status >= 500 || response.status === 429 || code === "not-ready" || code === "server-error";
332
+ if (endpoint === ENQUEUE_ENDPOINT && response.status === 409 && !RETRYABLE_AT_ENQUEUE.has(code)) {
333
+ throw new EnqueueRefused(message, code);
334
+ }
335
+ throw new RelayUnavailable(
336
+ `${endpoint}: ${code || "refused"} \u2014 ${message}`,
337
+ retryable,
338
+ code
339
+ );
187
340
  }
188
- async #post(path, body) {
189
- const response = await this.#fetch(`${this.#options.relayOrigin}${path}`, {
190
- method: "POST",
191
- headers: { "content-type": "application/json" },
192
- body: JSON.stringify(body)
341
+ async #post(endpoint, body) {
342
+ const rawBody = JSON.stringify({
343
+ protocolVersion: PROTOCOL_VERSION,
344
+ ...body
193
345
  });
194
- return response.json();
346
+ const response = await this.#fetch(
347
+ `${this.#options.relayOrigin}/relay/site/${endpoint}`,
348
+ {
349
+ method: "POST",
350
+ headers: {
351
+ "content-type": "application/json",
352
+ ...this.#headers(endpoint, rawBody)
353
+ },
354
+ body: rawBody
355
+ }
356
+ );
357
+ return this.#answer(response, endpoint);
195
358
  }
196
- async #get(path) {
197
- const url = `${this.#options.relayOrigin}${path}?siteId=${encodeURIComponent(this.#options.siteId)}`;
198
- const response = await this.#fetch(url);
199
- return response.json();
359
+ async #get(endpoint) {
360
+ const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}&protocolVersion=${encodeURIComponent(PROTOCOL_VERSION)}`;
361
+ const response = await this.#fetch(url, {
362
+ headers: this.#headers(endpoint, "")
363
+ });
364
+ return this.#answer(response, endpoint);
200
365
  }
201
366
  };
202
- var ENVELOPE_TTL_FALLBACK = 24 * 60 * 6e4;
203
367
 
204
368
  // src/app.ts
205
369
  var DEFAULT_LIVENESS_MS = 35e3;
370
+ var ENQUEUE_OPTIONS = Object.freeze({
371
+ kind: true,
372
+ payload: true,
373
+ owner: true,
374
+ audience: true,
375
+ purpose: true,
376
+ audienceAllow: true,
377
+ dependsOn: true,
378
+ ttlMs: true,
379
+ deadlineAt: true,
380
+ id: true
381
+ });
206
382
  var ByollmApp = class {
207
383
  #store;
208
384
  #siteKeys;
@@ -225,28 +401,38 @@ var ByollmApp = class {
225
401
  const deps = {
226
402
  ...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
227
403
  read: (jobId) => this.result(jobId),
228
- availability: async (jobId) => {
229
- const job = await this.#store.get(jobId);
230
- if (!job)
231
- return { available: false, reason: "unknown-job", blocked: false };
232
- if (job.claimableAt === null) {
233
- return { available: true, blocked: true };
234
- }
235
- const availability = await this.runnerAvailability({
236
- kind: job.kind,
237
- owner: job.owner,
238
- audience: job.audience,
239
- ...job.audienceAllow === void 0 ? {} : { audienceAllow: job.audienceAllow }
240
- });
241
- return {
242
- available: availability.available,
243
- ...availability.reason === void 0 ? {} : { reason: availability.reason },
244
- blocked: false
245
- };
246
- }
404
+ ...this.cloud !== void 0 ? {} : { availability: this.#availabilityFor() }
247
405
  };
248
406
  this.#delivery = options.delivery?.(deps) ?? new PollingDelivery(deps);
249
407
  }
408
+ /**
409
+ * The no-runner instrument, for a lane that can actually see runners.
410
+ *
411
+ * A method rather than an inline closure so the branch above reads as one
412
+ * decision — whether this deployment has the instrument at all — instead of
413
+ * a conditional wrapped around thirty lines of body.
414
+ */
415
+ #availabilityFor() {
416
+ return async (jobId) => {
417
+ const job = await this.#store.get(jobId);
418
+ if (!job)
419
+ return { available: false, reason: "unknown-job", blocked: false };
420
+ if (job.claimableAt === null) {
421
+ return { available: true, blocked: true };
422
+ }
423
+ const availability = await this.runnerAvailability({
424
+ kind: job.kind,
425
+ owner: job.owner,
426
+ audience: job.audience,
427
+ ...job.audienceAllow === void 0 ? {} : { audienceAllow: job.audienceAllow }
428
+ });
429
+ return {
430
+ available: availability.available,
431
+ ...availability.reason === void 0 ? {} : { reason: availability.reason },
432
+ blocked: false
433
+ };
434
+ };
435
+ }
250
436
  /**
251
437
  * Enqueue a job.
252
438
  *
@@ -255,6 +441,19 @@ var ByollmApp = class {
255
441
  * the app is obliged to disclose that to whoever reads it.
256
442
  */
257
443
  async enqueue(input) {
444
+ const unknown = Object.keys(input).filter(
445
+ (key) => !(key in ENQUEUE_OPTIONS)
446
+ );
447
+ if (unknown.length > 0) {
448
+ throw new Error(
449
+ `enqueue does not understand ${unknown.map((k) => `\`${k}\``).join(", ")}. An option this @byollm/server does not know is refused rather than ignored, because an ignored option is a job that runs differently than you asked with nothing to see \u2014 most often an SDK older than the code calling it. Upgrade @byollm/server, or remove the option.`
450
+ );
451
+ }
452
+ if (this.cloud !== void 0 && input.audience !== void 0) {
453
+ throw new Error(
454
+ "enqueue does not take `audience` on the cloud lane. Who may serve a job is derived from the person's own mapping \u2014 the service they chose, its owner, and that owner's sharing \u2014 which your site is not told and cannot compute. Remove `audience`; ask for the kind and the purpose, and their decision does the rest."
455
+ );
456
+ }
258
457
  const parsed = KindedPayload.safeParse({
259
458
  kind: input.kind,
260
459
  payload: input.payload
@@ -282,6 +481,27 @@ var ByollmApp = class {
282
481
  const record = await this.#store.create(
283
482
  {
284
483
  ...input,
484
+ /**
485
+ * Derived here, because on the cloud lane it is derivable and nowhere
486
+ * else knows the lane.
487
+ *
488
+ * Refusing the site's declaration is only half of "derived, never
489
+ * declared" — the stub still carries an audience to the relay, and a
490
+ * store that defaults it to `private` would keep every cloud job
491
+ * private no matter who was forbidden from saying so. The half that
492
+ * fixes anything is this one.
493
+ *
494
+ * `team` is the value that defers: it says a device whose owner
495
+ * admits this person may serve, and the hub then decides whether one
496
+ * does, from the mapping the person authored, its service's owner,
497
+ * that owner's offer scope, and the roster. Nothing is widened by
498
+ * saying it — both axes still have to agree, and the owner's scope is
499
+ * the other axis.
500
+ *
501
+ * Direct mode keeps the store's `private` default: there is no
502
+ * control plane there to derive from, and owner-only is the ruling.
503
+ */
504
+ ...this.cloud === void 0 ? {} : { audience: "team" },
285
505
  id: jobId,
286
506
  envelope,
287
507
  sizeClass: sizeClassOf(
@@ -314,7 +534,7 @@ var ByollmApp = class {
314
534
  * Check `provenance.untrusted` before rendering. It is true for every
315
535
  * `named`/`public` job, because that text came from someone else's machine
316
536
  * and the app must not present it as its own AI's answer
317
- * ({@link MUSTS.RESULT_PROVENANCE}).
537
+ * ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
318
538
  */
319
539
  async result(jobId) {
320
540
  const job = await this.job(jobId);
@@ -328,7 +548,11 @@ var ByollmApp = class {
328
548
  }
329
549
  /** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
330
550
  async cancel(jobId) {
331
- return this.#store.cancel(jobId, this.#now());
551
+ const cancelled = await this.#store.cancel(jobId, this.#now());
552
+ if (cancelled && this.cloud) {
553
+ await this.cloud.cancel(jobId).catch(() => void 0);
554
+ }
555
+ return cancelled;
332
556
  }
333
557
  /**
334
558
  * Is there a live runner that could take a job of this shape?
@@ -337,6 +561,11 @@ var ByollmApp = class {
337
561
  * signal cannot promise a runner the claim would then refuse.
338
562
  */
339
563
  async runnerAvailability(query) {
564
+ if (this.cloud !== void 0) {
565
+ throw new Error(
566
+ "runnerAvailability cannot answer on the cloud lane. It counts runners this site knows about, and on the cloud lane devices pair with the relay rather than with you \u2014 so the answer would be `none` whatever the truth is. Enqueue the job: the result says whether it ran, and the person's own dashboard says why not."
567
+ );
568
+ }
340
569
  const now = this.#now();
341
570
  const all = await this.#store.listRunners();
342
571
  const live = all.filter(
@@ -350,37 +579,41 @@ var ByollmApp = class {
350
579
  }
351
580
  let capable = 0;
352
581
  let admitted = 0;
582
+ let lastRefusal;
353
583
  for (const runner of live) {
354
- const capability = runner.capabilities.find((c) => c.kind === query.kind);
355
- if (!capability) continue;
356
- capable += 1;
357
- const match = matchAudience(
358
- {
359
- owner: query.owner,
360
- audience: query.audience ?? "self",
361
- audienceAllow: query.audienceAllow
362
- },
363
- {
364
- owner: runner.owner,
365
- offerScope: capability.offerScope,
366
- // A generic backend's cost depends on its base URL, which the
367
- // server never sees; assume the expensive reading (byollm_007 §4).
368
- cost: backendDescriptor(capability.backendId).cost ?? "metered",
369
- // Consent is the daemon's to hold, and it has already applied it:
370
- // the offer scope arriving here is the *effective* one, so a
371
- // metered backend nobody agreed to share advertises `self` and is
372
- // refused by the scope rule above. Re-deriving consent from
373
- // `false` here would instead refuse every backend an owner
374
- // deliberately shared, because the server has no way to learn they
375
- // did the signal would be wrong in the direction that breaks
376
- // working setups.
377
- spend: { acknowledged: true },
378
- // Same conservative assumption the claim path makes: the server
379
- // cannot see a remote daemon's local allowlist (protocol §4.2).
380
- locallyAllows: () => true
381
- }
382
- );
383
- if (match.ok) admitted += 1;
584
+ for (const capability of runner.capabilities.filter(
585
+ (c) => c.kind === query.kind
586
+ )) {
587
+ capable += 1;
588
+ const match = matchAudience(
589
+ {
590
+ owner: query.owner,
591
+ audience: query.audience ?? "private",
592
+ audienceAllow: query.audienceAllow
593
+ },
594
+ {
595
+ owner: runner.owner,
596
+ offerScope: capability.offerScope,
597
+ // A generic backend's cost depends on its base URL, which the
598
+ // server never sees; assume the expensive reading (byollm_007 §4).
599
+ cost: backendDescriptor(capability.backendId).cost ?? "metered",
600
+ // Consent is the daemon's to hold, and it has already applied it:
601
+ // the offer scope arriving here is the *effective* one, so a
602
+ // metered backend nobody agreed to share advertises `self` and is
603
+ // refused by the scope rule above. Re-deriving consent from
604
+ // `false` here would instead refuse every backend an owner
605
+ // deliberately shared, because the server has no way to learn they
606
+ // did — the signal would be wrong in the direction that breaks
607
+ // working setups.
608
+ spend: { acknowledged: true },
609
+ // Same conservative assumption the claim path makes: the server
610
+ // cannot see a remote daemon's local allowlist (protocol §4.2).
611
+ admits: () => true
612
+ }
613
+ );
614
+ if (match.ok) admitted += 1;
615
+ else lastRefusal = match.refusal;
616
+ }
384
617
  }
385
618
  if (capable === 0) {
386
619
  return {
@@ -390,9 +623,10 @@ var ByollmApp = class {
390
623
  };
391
624
  }
392
625
  if (admitted === 0) {
626
+ const ownersDoing = lastRefusal === "offer-scope-too-narrow" || lastRefusal === "subscription-self-lock" || lastRefusal === "metered-no-spend-consent" || lastRefusal === "metered-ceiling-reached";
393
627
  return {
394
628
  available: false,
395
- reason: "audience-admits-nobody",
629
+ reason: ownersDoing ? "default-unusable" : "audience-admits-nobody",
396
630
  candidates: 0
397
631
  };
398
632
  }
@@ -406,13 +640,10 @@ var ByollmApp = class {
406
640
  * ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
407
641
  */
408
642
  async approvePairing(args) {
409
- const token = generateRunnerToken();
410
643
  return this.#store.approvePairing({
411
644
  userCode: normalizeUserCode(args.userCode),
412
645
  owner: args.owner,
413
646
  runnerId: generateRunnerId(),
414
- runnerToken: token,
415
- tokenHash: hashSecret(token),
416
647
  now: this.#now()
417
648
  });
418
649
  }
@@ -485,13 +716,27 @@ function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
485
716
  }
486
717
  function formatSiteKeys(keys) {
487
718
  const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
488
- return `# Set this as BYOLLM_SITE_KEYS. It is a secret: it is this site's
489
- # identity, and anything holding it can be this site.
719
+ const pub = publicIdentityOf3(keys);
720
+ return `# \u2500\u2500 1. SECRET \u2014 set this on your server, and nowhere else \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
721
+ #
722
+ # This is the site's identity. Anything holding it can *be* this site,
723
+ # so it goes wherever your deployment keeps secrets \u2014 never in a repo,
724
+ # never in a browser, never pasted into a dashboard.
490
725
  BYOLLM_SITE_KEYS=${encoded}
491
726
 
492
- # Fingerprint (not secret \u2014 show it to users so they can check what
493
- # their daemon pinned):
494
- # ${fingerprint(publicIdentityOf3(keys).identity)}
727
+ # \u2500\u2500 2. PUBLIC \u2014 paste this line into the byollm dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
728
+ #
729
+ # The public half. It proves signatures and seals nothing, so it is
730
+ # safe to publish \u2014 which is the point: users pin it, and the relay
731
+ # cannot forge work without the secret above.
732
+ ${JSON.stringify(pub)}
733
+
734
+ # \u2500\u2500 3. Fingerprint \u2014 what a person compares by eye \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
735
+ #
736
+ # A fingerprint is not secret. Show it on your site so somebody
737
+ # connecting can check it against what their daemon printed.
738
+ # The dashboard derives this itself, so there is nothing to paste.
739
+ # ${fingerprint(pub.identity)}
495
740
  `;
496
741
  }
497
742
 
@@ -525,12 +770,14 @@ var MemoryStore = class {
525
770
  kind: input.kind,
526
771
  envelope: input.envelope,
527
772
  sizeClass: input.sizeClass,
528
- audience: input.audience ?? "self",
773
+ audience: input.audience ?? "private",
774
+ purpose: input.purpose,
529
775
  owner: input.owner,
530
776
  audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
531
777
  dependsOn,
532
778
  state: "queued",
533
779
  lease: null,
780
+ completedByLeaseId: null,
534
781
  createdAt: now,
535
782
  // The TTL clock starts here only if nothing blocks the job.
536
783
  claimableAt: blocked ? null : now,
@@ -608,7 +855,7 @@ var MemoryStore = class {
608
855
  // not pretend to (protocol §4.2). It admits the job here; the daemon
609
856
  // is the enforcing side and releases with `refused` if its own list
610
857
  // says no.
611
- locallyAllows: () => true
858
+ admits: () => true
612
859
  }
613
860
  );
614
861
  return match.ok;
@@ -620,11 +867,11 @@ var MemoryStore = class {
620
867
  for (const { jobId, leaseId } of args.leases) {
621
868
  const job = this.#jobs.get(jobId);
622
869
  if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
623
- lost.push(jobId);
870
+ lost.push({ jobId, leaseId });
624
871
  continue;
625
872
  }
626
873
  if (job.state !== "claimed" && job.state !== "running") {
627
- lost.push(jobId);
874
+ lost.push({ jobId, leaseId });
628
875
  continue;
629
876
  }
630
877
  const expiresAt = args.now + args.leaseMs;
@@ -666,6 +913,11 @@ var MemoryStore = class {
666
913
  const job = this.#jobs.get(args.jobId);
667
914
  if (!job) return Promise.resolve({ accepted: false, job: null });
668
915
  if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
916
+ const sameDevice = job.provenance?.runnerId !== void 0 && job.provenance.runnerId === args.runnerId;
917
+ const sameGrant = args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
918
+ if (sameDevice && sameGrant) {
919
+ return Promise.resolve({ accepted: false, duplicate: true, job });
920
+ }
669
921
  return Promise.resolve({ accepted: false, job });
670
922
  }
671
923
  if (job.state === "expired") {
@@ -680,6 +932,8 @@ var MemoryStore = class {
680
932
  ...job,
681
933
  state,
682
934
  lease: null,
935
+ // The grant that recorded it, kept after the lease is dropped — §3.6.
936
+ completedByLeaseId: args.holder.by === "lease" ? args.holder.leaseId : job.lease?.id ?? null,
683
937
  outcome: args.outcome,
684
938
  provenance: args.provenance,
685
939
  updatedAt: args.now
@@ -768,6 +1022,7 @@ var MemoryStore = class {
768
1022
  ...job,
769
1023
  state: "queued",
770
1024
  lease: null,
1025
+ completedByLeaseId: null,
771
1026
  // Newly available again, so the TTL clock restarts here too.
772
1027
  claimableAt: args.now,
773
1028
  // A refusal is remembered, or the pair spins between claim and
@@ -797,6 +1052,7 @@ var MemoryStore = class {
797
1052
  ...job,
798
1053
  state: "queued",
799
1054
  lease: null,
1055
+ completedByLeaseId: null,
800
1056
  // The TTL clock restarts: it measures how long a job has waited
801
1057
  // *unclaimed*, and this job has just become available again. Without
802
1058
  // this, a job whose runner died would expire for time it spent being
@@ -819,6 +1075,7 @@ var MemoryStore = class {
819
1075
  ...job,
820
1076
  state: "expired",
821
1077
  lease: null,
1078
+ completedByLeaseId: null,
822
1079
  updatedAt: now
823
1080
  };
824
1081
  this.#write(job.id, expired);
@@ -834,6 +1091,7 @@ var MemoryStore = class {
834
1091
  ...job,
835
1092
  state: "canceled",
836
1093
  lease: null,
1094
+ completedByLeaseId: null,
837
1095
  updatedAt: now
838
1096
  };
839
1097
  this.#write(jobId, canceled);
@@ -854,9 +1112,7 @@ var MemoryStore = class {
854
1112
  }
855
1113
  listCancelRequests(runnerId) {
856
1114
  return Promise.resolve(
857
- [...this.#cancelRequests].filter(
858
- (jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
859
- )
1115
+ [...this.#cancelRequests].map((jobId) => ({ jobId, lease: this.#jobs.get(jobId)?.lease })).filter((row) => row.lease?.runnerId === runnerId).map((row) => ({ jobId: row.jobId, leaseId: row.lease?.id ?? "" }))
860
1116
  );
861
1117
  }
862
1118
  // -- pairing and runners -------------------------------------------------
@@ -887,7 +1143,6 @@ var MemoryStore = class {
887
1143
  const runner = {
888
1144
  id: args.runnerId,
889
1145
  owner: args.owner,
890
- tokenHash: args.tokenHash,
891
1146
  // Carried from the pairing, not re-supplied at approval: the user
892
1147
  // approved a specific machine, and the runner must be that machine.
893
1148
  device: pairing.device,
@@ -906,7 +1161,7 @@ var MemoryStore = class {
906
1161
  state: "approved",
907
1162
  owner: args.owner,
908
1163
  runnerId: runner.id,
909
- runnerTokenOnce: args.runnerToken
1164
+ collected: false
910
1165
  });
911
1166
  return Promise.resolve(runner);
912
1167
  }
@@ -927,17 +1182,11 @@ var MemoryStore = class {
927
1182
  if (pairing) {
928
1183
  this.#pairings.set(deviceCodeHash, {
929
1184
  ...pairing,
930
- runnerTokenOnce: null
1185
+ collected: true
931
1186
  });
932
1187
  }
933
1188
  return Promise.resolve();
934
1189
  }
935
- getRunnerByTokenHash(hash) {
936
- for (const runner of this.#runners.values()) {
937
- if (runner.tokenHash === hash) return Promise.resolve(runner);
938
- }
939
- return Promise.resolve(null);
940
- }
941
1190
  getRunner(runnerId) {
942
1191
  return Promise.resolve(this.#runners.get(runnerId) ?? null);
943
1192
  }
@@ -980,9 +1229,11 @@ export {
980
1229
  ByollmApp,
981
1230
  ByollmHandlers,
982
1231
  CloudLane,
1232
+ EnqueueRefused,
983
1233
  MemoryStore,
984
1234
  NoRunnerAvailableError,
985
1235
  PollingDelivery,
1236
+ RelayUnavailable,
986
1237
  ResultTimeoutError,
987
1238
  SERVED_PROTOCOL_VERSION,
988
1239
  capabilityFor,
@@ -991,7 +1242,6 @@ export {
991
1242
  generateDeviceCode,
992
1243
  generateJobId,
993
1244
  generateRunnerId,
994
- generateRunnerToken,
995
1245
  generateSiteKeys,
996
1246
  generateUserCode,
997
1247
  hashSecret,