@byollm/server 0.1.0-alpha.8 → 0.1.0-alpha.81

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,13 +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
43
  provenanceFor,
43
44
  signSiteRequest
44
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";
45
68
  var CloudLane = class {
46
69
  #options;
47
70
  #store;
@@ -67,20 +90,64 @@ var CloudLane = class {
67
90
  id: record.id,
68
91
  kind: record.kind,
69
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),
70
98
  audience: record.audience,
71
- ...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 },
72
117
  sizeClass: record.sizeClass,
73
118
  streaming: false,
74
119
  // The relay needs *a* deadline to bound routing. A job without one gets
75
120
  // the envelope's, which is the outer bound on how long the ciphertext
76
121
  // is worth carrying — never longer than the work could possibly matter.
77
- 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())
78
130
  };
79
131
  await this.#post("enqueue", {
80
132
  siteId: this.#options.siteId,
81
133
  stub
82
134
  });
83
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
+ }
84
151
  /**
85
152
  * One cycle: seal for anything claimed, collect anything finished.
86
153
  *
@@ -93,6 +160,16 @@ var CloudLane = class {
93
160
  const sealed = [];
94
161
  const refused = [];
95
162
  const completed = [];
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) {
96
173
  const pending = await this.#get("pending");
97
174
  for (const claim of pending.jobs) {
98
175
  const record = await this.#store.get(claim.jobId);
@@ -110,12 +187,16 @@ var CloudLane = class {
110
187
  refused.push(claim.jobId);
111
188
  continue;
112
189
  }
113
- await this.#store.adopt({
190
+ const adopted = await this.#store.adopt({
114
191
  jobId: claim.jobId,
115
192
  leaseId: claim.leaseId,
116
- expiresAt: claim.awaitingUntil,
193
+ expiresAt: claim.leaseExpiresAt,
117
194
  now: this.#now()
118
195
  });
196
+ if (!adopted) {
197
+ refused.push(claim.jobId);
198
+ continue;
199
+ }
119
200
  await this.#post("payload", {
120
201
  siteId: this.#options.siteId,
121
202
  jobId: claim.jobId,
@@ -136,17 +217,30 @@ var CloudLane = class {
136
217
  }
137
218
  await this.#store.complete({
138
219
  jobId: done.jobId,
220
+ // The relay named the device; the signature above proved it — §3.6.
221
+ runnerId: done.runnerId,
139
222
  // The grant, not the machine: this site never paired with the device
140
223
  // that ran it, and the signature it verified above is the stronger
141
224
  // claim about who did.
142
225
  holder: { by: "lease", leaseId: done.leaseId },
143
- outcome,
226
+ outcome: outcome.outcome,
144
227
  provenance: provenanceFor({
145
228
  audience: record.audience,
146
229
  runnerId: done.runnerId,
147
- runnerOwner: keyId(done.device.identity),
148
- backendClass: "http",
149
- 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
150
244
  }),
151
245
  now: this.#now()
152
246
  });
@@ -181,10 +275,10 @@ var CloudLane = class {
181
275
  } catch {
182
276
  return null;
183
277
  }
184
- const outcome = JobOutcome.safeParse(parsed);
185
- if (!outcome.success) return null;
186
- if (outcome.data.outcome !== done.disposition) return null;
187
- 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;
188
282
  }
189
283
  /**
190
284
  * Sign a site-plane call with this site's identity key.
@@ -208,8 +302,47 @@ var CloudLane = class {
208
302
  "x-byollm-signature": signature.signature
209
303
  };
210
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
+ );
340
+ }
211
341
  async #post(endpoint, body) {
212
- const rawBody = JSON.stringify(body);
342
+ const rawBody = JSON.stringify({
343
+ protocolVersion: PROTOCOL_VERSION,
344
+ ...body
345
+ });
213
346
  const response = await this.#fetch(
214
347
  `${this.#options.relayOrigin}/relay/site/${endpoint}`,
215
348
  {
@@ -221,20 +354,31 @@ var CloudLane = class {
221
354
  body: rawBody
222
355
  }
223
356
  );
224
- return response.json();
357
+ return this.#answer(response, endpoint);
225
358
  }
226
359
  async #get(endpoint) {
227
- const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}`;
360
+ const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}&protocolVersion=${encodeURIComponent(PROTOCOL_VERSION)}`;
228
361
  const response = await this.#fetch(url, {
229
362
  headers: this.#headers(endpoint, "")
230
363
  });
231
- return response.json();
364
+ return this.#answer(response, endpoint);
232
365
  }
233
366
  };
234
- var ENVELOPE_TTL_FALLBACK = 24 * 60 * 6e4;
235
367
 
236
368
  // src/app.ts
237
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
+ });
238
382
  var ByollmApp = class {
239
383
  #store;
240
384
  #siteKeys;
@@ -257,28 +401,38 @@ var ByollmApp = class {
257
401
  const deps = {
258
402
  ...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
259
403
  read: (jobId) => this.result(jobId),
260
- availability: async (jobId) => {
261
- const job = await this.#store.get(jobId);
262
- if (!job)
263
- return { available: false, reason: "unknown-job", blocked: false };
264
- if (job.claimableAt === null) {
265
- return { available: true, blocked: true };
266
- }
267
- const availability = await this.runnerAvailability({
268
- kind: job.kind,
269
- owner: job.owner,
270
- audience: job.audience,
271
- ...job.audienceAllow === void 0 ? {} : { audienceAllow: job.audienceAllow }
272
- });
273
- return {
274
- available: availability.available,
275
- ...availability.reason === void 0 ? {} : { reason: availability.reason },
276
- blocked: false
277
- };
278
- }
404
+ ...this.cloud !== void 0 ? {} : { availability: this.#availabilityFor() }
279
405
  };
280
406
  this.#delivery = options.delivery?.(deps) ?? new PollingDelivery(deps);
281
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
+ }
282
436
  /**
283
437
  * Enqueue a job.
284
438
  *
@@ -287,6 +441,19 @@ var ByollmApp = class {
287
441
  * the app is obliged to disclose that to whoever reads it.
288
442
  */
289
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
+ }
290
457
  const parsed = KindedPayload.safeParse({
291
458
  kind: input.kind,
292
459
  payload: input.payload
@@ -314,6 +481,27 @@ var ByollmApp = class {
314
481
  const record = await this.#store.create(
315
482
  {
316
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" },
317
505
  id: jobId,
318
506
  envelope,
319
507
  sizeClass: sizeClassOf(
@@ -346,7 +534,7 @@ var ByollmApp = class {
346
534
  * Check `provenance.untrusted` before rendering. It is true for every
347
535
  * `named`/`public` job, because that text came from someone else's machine
348
536
  * and the app must not present it as its own AI's answer
349
- * ({@link MUSTS.RESULT_PROVENANCE}).
537
+ * ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
350
538
  */
351
539
  async result(jobId) {
352
540
  const job = await this.job(jobId);
@@ -360,7 +548,11 @@ var ByollmApp = class {
360
548
  }
361
549
  /** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
362
550
  async cancel(jobId) {
363
- 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;
364
556
  }
365
557
  /**
366
558
  * Is there a live runner that could take a job of this shape?
@@ -369,6 +561,11 @@ var ByollmApp = class {
369
561
  * signal cannot promise a runner the claim would then refuse.
370
562
  */
371
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
+ }
372
569
  const now = this.#now();
373
570
  const all = await this.#store.listRunners();
374
571
  const live = all.filter(
@@ -382,37 +579,41 @@ var ByollmApp = class {
382
579
  }
383
580
  let capable = 0;
384
581
  let admitted = 0;
582
+ let lastRefusal;
385
583
  for (const runner of live) {
386
- const capability = runner.capabilities.find((c) => c.kind === query.kind);
387
- if (!capability) continue;
388
- capable += 1;
389
- const match = matchAudience(
390
- {
391
- owner: query.owner,
392
- audience: query.audience ?? "self",
393
- audienceAllow: query.audienceAllow
394
- },
395
- {
396
- owner: runner.owner,
397
- offerScope: capability.offerScope,
398
- // A generic backend's cost depends on its base URL, which the
399
- // server never sees; assume the expensive reading (byollm_007 §4).
400
- cost: backendDescriptor(capability.backendId).cost ?? "metered",
401
- // Consent is the daemon's to hold, and it has already applied it:
402
- // the offer scope arriving here is the *effective* one, so a
403
- // metered backend nobody agreed to share advertises `self` and is
404
- // refused by the scope rule above. Re-deriving consent from
405
- // `false` here would instead refuse every backend an owner
406
- // deliberately shared, because the server has no way to learn they
407
- // did the signal would be wrong in the direction that breaks
408
- // working setups.
409
- spend: { acknowledged: true },
410
- // Same conservative assumption the claim path makes: the server
411
- // cannot see a remote daemon's local allowlist (protocol §4.2).
412
- locallyAllows: () => true
413
- }
414
- );
415
- 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
+ }
416
617
  }
417
618
  if (capable === 0) {
418
619
  return {
@@ -422,9 +623,10 @@ var ByollmApp = class {
422
623
  };
423
624
  }
424
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";
425
627
  return {
426
628
  available: false,
427
- reason: "audience-admits-nobody",
629
+ reason: ownersDoing ? "default-unusable" : "audience-admits-nobody",
428
630
  candidates: 0
429
631
  };
430
632
  }
@@ -438,13 +640,10 @@ var ByollmApp = class {
438
640
  * ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
439
641
  */
440
642
  async approvePairing(args) {
441
- const token = generateRunnerToken();
442
643
  return this.#store.approvePairing({
443
644
  userCode: normalizeUserCode(args.userCode),
444
645
  owner: args.owner,
445
646
  runnerId: generateRunnerId(),
446
- runnerToken: token,
447
- tokenHash: hashSecret(token),
448
647
  now: this.#now()
449
648
  });
450
649
  }
@@ -517,13 +716,27 @@ function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
517
716
  }
518
717
  function formatSiteKeys(keys) {
519
718
  const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
520
- return `# Set this as BYOLLM_SITE_KEYS. It is a secret: it is this site's
521
- # 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.
522
725
  BYOLLM_SITE_KEYS=${encoded}
523
726
 
524
- # Fingerprint (not secret \u2014 show it to users so they can check what
525
- # their daemon pinned):
526
- # ${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)}
527
740
  `;
528
741
  }
529
742
 
@@ -557,12 +770,14 @@ var MemoryStore = class {
557
770
  kind: input.kind,
558
771
  envelope: input.envelope,
559
772
  sizeClass: input.sizeClass,
560
- audience: input.audience ?? "self",
773
+ audience: input.audience ?? "private",
774
+ purpose: input.purpose,
561
775
  owner: input.owner,
562
776
  audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
563
777
  dependsOn,
564
778
  state: "queued",
565
779
  lease: null,
780
+ completedByLeaseId: null,
566
781
  createdAt: now,
567
782
  // The TTL clock starts here only if nothing blocks the job.
568
783
  claimableAt: blocked ? null : now,
@@ -640,7 +855,7 @@ var MemoryStore = class {
640
855
  // not pretend to (protocol §4.2). It admits the job here; the daemon
641
856
  // is the enforcing side and releases with `refused` if its own list
642
857
  // says no.
643
- locallyAllows: () => true
858
+ admits: () => true
644
859
  }
645
860
  );
646
861
  return match.ok;
@@ -652,11 +867,11 @@ var MemoryStore = class {
652
867
  for (const { jobId, leaseId } of args.leases) {
653
868
  const job = this.#jobs.get(jobId);
654
869
  if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
655
- lost.push(jobId);
870
+ lost.push({ jobId, leaseId });
656
871
  continue;
657
872
  }
658
873
  if (job.state !== "claimed" && job.state !== "running") {
659
- lost.push(jobId);
874
+ lost.push({ jobId, leaseId });
660
875
  continue;
661
876
  }
662
877
  const expiresAt = args.now + args.leaseMs;
@@ -698,6 +913,11 @@ var MemoryStore = class {
698
913
  const job = this.#jobs.get(args.jobId);
699
914
  if (!job) return Promise.resolve({ accepted: false, job: null });
700
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
+ }
701
921
  return Promise.resolve({ accepted: false, job });
702
922
  }
703
923
  if (job.state === "expired") {
@@ -712,6 +932,8 @@ var MemoryStore = class {
712
932
  ...job,
713
933
  state,
714
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,
715
937
  outcome: args.outcome,
716
938
  provenance: args.provenance,
717
939
  updatedAt: args.now
@@ -800,6 +1022,7 @@ var MemoryStore = class {
800
1022
  ...job,
801
1023
  state: "queued",
802
1024
  lease: null,
1025
+ completedByLeaseId: null,
803
1026
  // Newly available again, so the TTL clock restarts here too.
804
1027
  claimableAt: args.now,
805
1028
  // A refusal is remembered, or the pair spins between claim and
@@ -829,6 +1052,7 @@ var MemoryStore = class {
829
1052
  ...job,
830
1053
  state: "queued",
831
1054
  lease: null,
1055
+ completedByLeaseId: null,
832
1056
  // The TTL clock restarts: it measures how long a job has waited
833
1057
  // *unclaimed*, and this job has just become available again. Without
834
1058
  // this, a job whose runner died would expire for time it spent being
@@ -851,6 +1075,7 @@ var MemoryStore = class {
851
1075
  ...job,
852
1076
  state: "expired",
853
1077
  lease: null,
1078
+ completedByLeaseId: null,
854
1079
  updatedAt: now
855
1080
  };
856
1081
  this.#write(job.id, expired);
@@ -866,6 +1091,7 @@ var MemoryStore = class {
866
1091
  ...job,
867
1092
  state: "canceled",
868
1093
  lease: null,
1094
+ completedByLeaseId: null,
869
1095
  updatedAt: now
870
1096
  };
871
1097
  this.#write(jobId, canceled);
@@ -886,9 +1112,7 @@ var MemoryStore = class {
886
1112
  }
887
1113
  listCancelRequests(runnerId) {
888
1114
  return Promise.resolve(
889
- [...this.#cancelRequests].filter(
890
- (jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
891
- )
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 ?? "" }))
892
1116
  );
893
1117
  }
894
1118
  // -- pairing and runners -------------------------------------------------
@@ -919,7 +1143,6 @@ var MemoryStore = class {
919
1143
  const runner = {
920
1144
  id: args.runnerId,
921
1145
  owner: args.owner,
922
- tokenHash: args.tokenHash,
923
1146
  // Carried from the pairing, not re-supplied at approval: the user
924
1147
  // approved a specific machine, and the runner must be that machine.
925
1148
  device: pairing.device,
@@ -938,7 +1161,7 @@ var MemoryStore = class {
938
1161
  state: "approved",
939
1162
  owner: args.owner,
940
1163
  runnerId: runner.id,
941
- runnerTokenOnce: args.runnerToken
1164
+ collected: false
942
1165
  });
943
1166
  return Promise.resolve(runner);
944
1167
  }
@@ -959,17 +1182,11 @@ var MemoryStore = class {
959
1182
  if (pairing) {
960
1183
  this.#pairings.set(deviceCodeHash, {
961
1184
  ...pairing,
962
- runnerTokenOnce: null
1185
+ collected: true
963
1186
  });
964
1187
  }
965
1188
  return Promise.resolve();
966
1189
  }
967
- getRunnerByTokenHash(hash) {
968
- for (const runner of this.#runners.values()) {
969
- if (runner.tokenHash === hash) return Promise.resolve(runner);
970
- }
971
- return Promise.resolve(null);
972
- }
973
1190
  getRunner(runnerId) {
974
1191
  return Promise.resolve(this.#runners.get(runnerId) ?? null);
975
1192
  }
@@ -1012,9 +1229,11 @@ export {
1012
1229
  ByollmApp,
1013
1230
  ByollmHandlers,
1014
1231
  CloudLane,
1232
+ EnqueueRefused,
1015
1233
  MemoryStore,
1016
1234
  NoRunnerAvailableError,
1017
1235
  PollingDelivery,
1236
+ RelayUnavailable,
1018
1237
  ResultTimeoutError,
1019
1238
  SERVED_PROTOCOL_VERSION,
1020
1239
  capabilityFor,
@@ -1023,7 +1242,6 @@ export {
1023
1242
  generateDeviceCode,
1024
1243
  generateJobId,
1025
1244
  generateRunnerId,
1026
- generateRunnerToken,
1027
1245
  generateSiteKeys,
1028
1246
  generateUserCode,
1029
1247
  hashSecret,