@byollm/server 0.1.0-alpha.3 → 0.1.0-alpha.5

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,19 @@
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
9
  generateRunnerToken,
10
10
  generateUserCode,
11
11
  hashSecret,
12
+ resealForDevice,
12
13
  routeEndpoint,
13
- secretsMatch
14
- } from "./chunk-HL6EYHQ7.js";
14
+ secretsMatch,
15
+ signatureFrom
16
+ } from "./chunk-4NIHWQAT.js";
15
17
  import {
16
18
  NoRunnerAvailableError,
17
19
  PollingDelivery,
@@ -20,19 +22,206 @@ import {
20
22
 
21
23
  // src/app.ts
22
24
  import {
25
+ ENVELOPE_MAX_AGE_MS,
26
+ KindedPayload,
27
+ keyId as keyId2,
28
+ payloadTextLength,
29
+ publicIdentityOf as publicIdentityOf2,
30
+ seal,
31
+ sizeClassOf,
23
32
  backendDescriptor,
24
33
  matchAudience
25
34
  } from "@byollm/protocol";
35
+
36
+ // src/cloud.ts
37
+ import {
38
+ JobOutcome,
39
+ keyId,
40
+ open,
41
+ publicIdentityOf,
42
+ provenanceFor
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
+ audience: record.audience,
70
+ ...record.audienceAllow === void 0 ? {} : { audienceAllow: [...record.audienceAllow] },
71
+ sizeClass: record.sizeClass,
72
+ streaming: false,
73
+ // The relay needs *a* deadline to bound routing. A job without one gets
74
+ // the envelope's, which is the outer bound on how long the ciphertext
75
+ // is worth carrying — never longer than the work could possibly matter.
76
+ deadlineAt: record.deadlineAt ?? record.createdAt + ENVELOPE_TTL_FALLBACK
77
+ };
78
+ await this.#post("/relay/site/enqueue", {
79
+ siteId: this.#options.siteId,
80
+ stub
81
+ });
82
+ }
83
+ /**
84
+ * One cycle: seal for anything claimed, collect anything finished.
85
+ *
86
+ * Idempotent and safe to call as often as you like. Exposed as a single
87
+ * cycle rather than hidden behind a timer so a caller decides its own
88
+ * cadence — a serverless site runs it on a cron, a long-lived one on an
89
+ * interval, and a test runs it exactly when it means to.
90
+ */
91
+ async pump() {
92
+ const sealed = [];
93
+ const refused = [];
94
+ const completed = [];
95
+ const pending = await this.#get("/relay/site/pending");
96
+ for (const claim of pending.jobs) {
97
+ const record = await this.#store.get(claim.jobId);
98
+ if (!record) continue;
99
+ const resealed = await resealForDevice({
100
+ siteKeys: this.#siteKeys,
101
+ job: {
102
+ id: record.id,
103
+ envelope: record.envelope,
104
+ createdAt: record.createdAt
105
+ },
106
+ device: claim.device
107
+ });
108
+ if (!resealed.ok) {
109
+ refused.push(claim.jobId);
110
+ continue;
111
+ }
112
+ await this.#store.adopt({
113
+ jobId: claim.jobId,
114
+ leaseId: claim.leaseId,
115
+ expiresAt: claim.awaitingUntil,
116
+ now: this.#now()
117
+ });
118
+ await this.#post("/relay/site/payload", {
119
+ siteId: this.#options.siteId,
120
+ jobId: claim.jobId,
121
+ envelope: resealed.envelope
122
+ });
123
+ sealed.push(claim.jobId);
124
+ }
125
+ const finished = await this.#get("/relay/site/results");
126
+ for (const done of finished.jobs) {
127
+ const record = await this.#store.get(done.jobId);
128
+ if (!record || record.state === "ok" || record.state === "error") {
129
+ continue;
130
+ }
131
+ const outcome = await this.#openResult(done);
132
+ if (!outcome) {
133
+ refused.push(done.jobId);
134
+ continue;
135
+ }
136
+ await this.#store.complete({
137
+ jobId: done.jobId,
138
+ // The grant, not the machine: this site never paired with the device
139
+ // that ran it, and the signature it verified above is the stronger
140
+ // claim about who did.
141
+ holder: { by: "lease", leaseId: done.leaseId },
142
+ outcome,
143
+ provenance: provenanceFor({
144
+ audience: record.audience,
145
+ runnerId: done.runnerId,
146
+ runnerOwner: keyId(done.device.identity),
147
+ backendClass: "http",
148
+ model: "unknown"
149
+ }),
150
+ now: this.#now()
151
+ });
152
+ completed.push(done.jobId);
153
+ }
154
+ return { sealed, completed, refused };
155
+ }
156
+ /**
157
+ * Open a sealed result and verify it came from the device that claimed it.
158
+ *
159
+ * The relay says which device ran the job; this checks that claim against a
160
+ * signature the relay cannot produce. A relay that named the wrong device
161
+ * gets a refusal, not a stored result — which is what keeps `RELAY_BLIND`
162
+ * from quietly becoming `RELAY_TRUSTED`.
163
+ */
164
+ async #openResult(done) {
165
+ const opened = await open({
166
+ envelope: done.envelope,
167
+ recipientKeys: this.#siteKeys,
168
+ senderIdentityPublic: done.device.identity,
169
+ expected: {
170
+ jobId: done.jobId,
171
+ senderKeyId: keyId(done.device.identity),
172
+ recipientKeyId: keyId(publicIdentityOf(this.#siteKeys).identity),
173
+ direction: "result"
174
+ }
175
+ });
176
+ if (!opened.ok) return null;
177
+ let parsed;
178
+ try {
179
+ parsed = JSON.parse(opened.plaintext);
180
+ } catch {
181
+ return null;
182
+ }
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;
187
+ }
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)
193
+ });
194
+ return response.json();
195
+ }
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();
200
+ }
201
+ };
202
+ var ENVELOPE_TTL_FALLBACK = 24 * 60 * 6e4;
203
+
204
+ // src/app.ts
26
205
  var DEFAULT_LIVENESS_MS = 35e3;
27
206
  var ByollmApp = class {
28
207
  #store;
208
+ #siteKeys;
29
209
  #now;
30
210
  #livenessMs;
31
211
  #delivery;
212
+ /** Present only in the cloud lane; the site's side of the relay. */
213
+ cloud;
32
214
  constructor(options) {
33
215
  this.#store = options.store;
216
+ this.#siteKeys = options.siteKeys;
34
217
  this.#now = options.now ?? Date.now;
35
218
  this.#livenessMs = options.livenessMs ?? DEFAULT_LIVENESS_MS;
219
+ this.cloud = options.lane === void 0 ? void 0 : new CloudLane({
220
+ options: options.lane,
221
+ store: options.store,
222
+ siteKeys: options.siteKeys,
223
+ now: this.#now
224
+ });
36
225
  const deps = {
37
226
  ...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
38
227
  read: (jobId) => this.result(jobId),
@@ -66,7 +255,45 @@ var ByollmApp = class {
66
255
  * the app is obliged to disclose that to whoever reads it.
67
256
  */
68
257
  async enqueue(input) {
69
- const record = await this.#store.create(input, this.#now());
258
+ const parsed = KindedPayload.safeParse({
259
+ kind: input.kind,
260
+ payload: input.payload
261
+ });
262
+ if (!parsed.success) {
263
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
264
+ throw new Error(`invalid ${input.kind} payload \u2014 ${detail}`);
265
+ }
266
+ const createdAt = this.#now();
267
+ const envelopeDeadlineAt = createdAt + ENVELOPE_MAX_AGE_MS;
268
+ const jobId = input.id ?? generateJobId();
269
+ const senderKeyId = keyId2(publicIdentityOf2(this.#siteKeys).identity);
270
+ const envelope = await seal({
271
+ plaintext: JSON.stringify(parsed.data.payload),
272
+ senderKeys: this.#siteKeys,
273
+ recipientEncryptionPublic: this.#siteKeys.encryptionPublic,
274
+ context: {
275
+ jobId,
276
+ senderKeyId,
277
+ recipientKeyId: senderKeyId,
278
+ deadlineAt: envelopeDeadlineAt,
279
+ direction: "payload"
280
+ }
281
+ });
282
+ const record = await this.#store.create(
283
+ {
284
+ ...input,
285
+ id: jobId,
286
+ envelope,
287
+ sizeClass: sizeClassOf(
288
+ payloadTextLength({
289
+ kind: input.kind,
290
+ payload: parsed.data.payload
291
+ })
292
+ )
293
+ },
294
+ createdAt
295
+ );
296
+ await this.cloud?.publish(record);
70
297
  return {
71
298
  id: record.id,
72
299
  record,
@@ -229,6 +456,45 @@ function normalizeUserCode(input) {
229
456
  return bare.length === 8 ? `${bare.slice(0, 4)}-${bare.slice(4)}` : bare;
230
457
  }
231
458
 
459
+ // src/keys.ts
460
+ import { StoredKeys, generateKeys, publicIdentityOf as publicIdentityOf3 } from "@byollm/protocol";
461
+ import { fingerprint } from "@byollm/protocol";
462
+ var generateSiteKeys = (now = Date.now()) => generateKeys(now);
463
+ function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
464
+ const raw = env[variable];
465
+ if (raw === void 0 || raw === "") {
466
+ throw new Error(
467
+ `${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.`
468
+ );
469
+ }
470
+ let parsed;
471
+ try {
472
+ parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
473
+ } catch {
474
+ throw new Error(
475
+ `${variable} is not base64-encoded JSON. It should be exactly what \`npx @byollm/server keygen\` printed.`
476
+ );
477
+ }
478
+ const result = StoredKeys.safeParse(parsed);
479
+ if (!result.success) {
480
+ throw new Error(
481
+ `${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.`
482
+ );
483
+ }
484
+ return result.data;
485
+ }
486
+ function formatSiteKeys(keys) {
487
+ 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.
490
+ BYOLLM_SITE_KEYS=${encoded}
491
+
492
+ # Fingerprint (not secret \u2014 show it to users so they can check what
493
+ # their daemon pinned):
494
+ # ${fingerprint(publicIdentityOf3(keys).identity)}
495
+ `;
496
+ }
497
+
232
498
  // src/memory.ts
233
499
  import {
234
500
  backendDescriptor as backendDescriptor2,
@@ -247,7 +513,7 @@ var MemoryStore = class {
247
513
  }
248
514
  // -- jobs ---------------------------------------------------------------
249
515
  create(input, now) {
250
- const id = input.id ?? generateJobId();
516
+ const id = input.id;
251
517
  const existing = this.#jobs.get(id);
252
518
  if (existing) return Promise.resolve(existing);
253
519
  const dependsOn = [...input.dependsOn ?? []];
@@ -257,7 +523,8 @@ var MemoryStore = class {
257
523
  const job = {
258
524
  id,
259
525
  kind: input.kind,
260
- payload: input.payload,
526
+ envelope: input.envelope,
527
+ sizeClass: input.sizeClass,
261
528
  audience: input.audience ?? "self",
262
529
  owner: input.owner,
263
530
  audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
@@ -275,7 +542,7 @@ var MemoryStore = class {
275
542
  provenance: null,
276
543
  updatedAt: now
277
544
  };
278
- this.#jobs.set(id, job);
545
+ this.#write(id, job);
279
546
  return Promise.resolve(job);
280
547
  }
281
548
  get(jobId) {
@@ -294,13 +561,16 @@ var MemoryStore = class {
294
561
  ...job,
295
562
  state: "claimed",
296
563
  lease: {
564
+ // A fresh id per grant. Two claims of the same job by the same
565
+ // runner are two different leases, and must be distinguishable.
566
+ id: generateLeaseId(),
297
567
  runnerId: args.runnerId,
298
568
  expiresAt: args.now + args.leaseMs
299
569
  },
300
570
  attempts: job.attempts + 1,
301
571
  updatedAt: args.now
302
572
  };
303
- this.#jobs.set(job.id, updated);
573
+ this.#write(job.id, updated);
304
574
  claimed.push(updated);
305
575
  }
306
576
  return Promise.resolve(claimed);
@@ -347,9 +617,9 @@ var MemoryStore = class {
347
617
  this.#expireDueSync(args.now);
348
618
  const renewed = [];
349
619
  const lost = [];
350
- for (const jobId of args.jobIds) {
620
+ for (const { jobId, leaseId } of args.leases) {
351
621
  const job = this.#jobs.get(jobId);
352
- if (!job || job.lease?.runnerId !== args.runnerId) {
622
+ if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
353
623
  lost.push(jobId);
354
624
  continue;
355
625
  }
@@ -358,16 +628,40 @@ var MemoryStore = class {
358
628
  continue;
359
629
  }
360
630
  const expiresAt = args.now + args.leaseMs;
361
- this.#jobs.set(jobId, {
631
+ this.#write(jobId, {
362
632
  ...job,
363
633
  state: "running",
364
- lease: { runnerId: args.runnerId, expiresAt },
634
+ // Renewal extends the existing grant; it does not mint a new one.
635
+ lease: { ...job.lease, expiresAt },
365
636
  updatedAt: args.now
366
637
  });
367
638
  renewed.push({ jobId, expiresAt });
368
639
  }
369
640
  return Promise.resolve({ renewed, lost });
370
641
  }
642
+ adopt(args) {
643
+ const job = this.#jobs.get(args.jobId);
644
+ if (!job) return Promise.resolve(null);
645
+ if (job.state !== "queued" && job.state !== "claimed") {
646
+ return Promise.resolve(null);
647
+ }
648
+ if (job.lease && job.lease.id !== args.leaseId) {
649
+ return Promise.resolve(null);
650
+ }
651
+ const updated = {
652
+ ...job,
653
+ state: "claimed",
654
+ lease: {
655
+ id: args.leaseId,
656
+ // No runner: this site never paired with the machine holding it.
657
+ runnerId: "",
658
+ expiresAt: args.expiresAt
659
+ },
660
+ updatedAt: args.now
661
+ };
662
+ this.#write(updated.id, updated);
663
+ return Promise.resolve(updated);
664
+ }
371
665
  complete(args) {
372
666
  const job = this.#jobs.get(args.jobId);
373
667
  if (!job) return Promise.resolve({ accepted: false, job: null });
@@ -377,7 +671,8 @@ var MemoryStore = class {
377
671
  if (job.state === "expired") {
378
672
  return Promise.resolve({ accepted: false, job });
379
673
  }
380
- if (job.lease?.runnerId !== args.runnerId) {
674
+ const holds = args.holder.by === "runner" ? job.lease?.runnerId === args.holder.runnerId : job.lease?.id === args.holder.leaseId;
675
+ if (!holds) {
381
676
  return Promise.resolve({ accepted: false, job });
382
677
  }
383
678
  const state = args.outcome.outcome === "ok" ? "ok" : args.outcome.outcome === "canceled" ? "canceled" : "error";
@@ -389,7 +684,7 @@ var MemoryStore = class {
389
684
  provenance: args.provenance,
390
685
  updatedAt: args.now
391
686
  };
392
- this.#jobs.set(job.id, updated);
687
+ this.#write(job.id, updated);
393
688
  this.#cancelRequests.delete(job.id);
394
689
  if (state === "ok") this.#unblockDependents(job.id, args.now);
395
690
  return Promise.resolve({ accepted: true, job: updated });
@@ -410,16 +705,66 @@ var MemoryStore = class {
410
705
  (depId) => this.#jobs.get(depId)?.state === "ok"
411
706
  );
412
707
  if (ready) {
413
- this.#jobs.set(job.id, { ...job, claimableAt: now, updatedAt: now });
708
+ this.#write(job.id, { ...job, claimableAt: now, updatedAt: now });
709
+ }
710
+ }
711
+ }
712
+ /**
713
+ * Watchers, by job id (byollm_009 §8.3).
714
+ *
715
+ * A `Set` per job so an unsubscribe removes exactly the handler it
716
+ * registered — two waiters on the same job are ordinary, and removing by
717
+ * job id alone would silently cancel someone else's wait.
718
+ */
719
+ #watchers = /* @__PURE__ */ new Map();
720
+ subscribe(jobId, onChange) {
721
+ const existing = this.#watchers.get(jobId) ?? /* @__PURE__ */ new Set();
722
+ existing.add(onChange);
723
+ this.#watchers.set(jobId, existing);
724
+ let live = true;
725
+ return () => {
726
+ if (!live) return;
727
+ live = false;
728
+ const set = this.#watchers.get(jobId);
729
+ set?.delete(onChange);
730
+ if (set?.size === 0) this.#watchers.delete(jobId);
731
+ };
732
+ }
733
+ /**
734
+ * The single write path for a job.
735
+ *
736
+ * Every mutation goes through here so notification cannot be forgotten by
737
+ * a future one. Nine call sites existed when the push seam was added, and
738
+ * "remember to notify" is not a property nine call sites keep.
739
+ */
740
+ #write(jobId, record) {
741
+ this.#jobs.set(jobId, record);
742
+ this.#notify(jobId);
743
+ }
744
+ /**
745
+ * Tell anyone watching that a job changed.
746
+ *
747
+ * A throwing watcher must not corrupt the store's own bookkeeping, so each
748
+ * is isolated: this runs inside write paths, and one bad listener taking
749
+ * out an unrelated write would be a far worse failure than a missed
750
+ * notification.
751
+ */
752
+ #notify(jobId) {
753
+ for (const watcher of this.#watchers.get(jobId) ?? []) {
754
+ try {
755
+ watcher();
756
+ } catch {
414
757
  }
415
758
  }
416
759
  }
417
760
  release(args) {
418
761
  const released = [];
419
- for (const jobId of args.jobIds) {
762
+ for (const { jobId, leaseId } of args.leases) {
420
763
  const job = this.#jobs.get(jobId);
421
- if (!job || job.lease?.runnerId !== args.runnerId) continue;
422
- this.#jobs.set(jobId, {
764
+ if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
765
+ continue;
766
+ }
767
+ this.#write(jobId, {
423
768
  ...job,
424
769
  state: "queued",
425
770
  lease: null,
@@ -461,7 +806,7 @@ var MemoryStore = class {
461
806
  claimableAt: now,
462
807
  updatedAt: now
463
808
  };
464
- this.#jobs.set(job.id, requeued);
809
+ this.#write(job.id, requeued);
465
810
  changed.push(requeued);
466
811
  }
467
812
  }
@@ -476,7 +821,7 @@ var MemoryStore = class {
476
821
  lease: null,
477
822
  updatedAt: now
478
823
  };
479
- this.#jobs.set(job.id, expired);
824
+ this.#write(job.id, expired);
480
825
  changed.push(expired);
481
826
  }
482
827
  return changed;
@@ -491,7 +836,7 @@ var MemoryStore = class {
491
836
  lease: null,
492
837
  updatedAt: now
493
838
  };
494
- this.#jobs.set(jobId, canceled);
839
+ this.#write(jobId, canceled);
495
840
  return Promise.resolve(canceled);
496
841
  }
497
842
  if (job.state === "claimed" || job.state === "running") {
@@ -543,6 +888,9 @@ var MemoryStore = class {
543
888
  id: args.runnerId,
544
889
  owner: args.owner,
545
890
  tokenHash: args.tokenHash,
891
+ // Carried from the pairing, not re-supplied at approval: the user
892
+ // approved a specific machine, and the runner must be that machine.
893
+ device: pairing.device,
546
894
  label: pairing.label,
547
895
  platform: pairing.platform,
548
896
  daemonVersion: pairing.daemonVersion,
@@ -631,22 +979,26 @@ function capabilityFor(capabilities, kind) {
631
979
  export {
632
980
  ByollmApp,
633
981
  ByollmHandlers,
982
+ CloudLane,
634
983
  MemoryStore,
635
984
  NoRunnerAvailableError,
636
985
  PollingDelivery,
637
986
  ResultTimeoutError,
638
987
  SERVED_PROTOCOL_VERSION,
639
- bearerFrom,
640
988
  capabilityFor,
641
989
  createFetchHandler,
990
+ formatSiteKeys,
642
991
  generateDeviceCode,
643
992
  generateJobId,
644
993
  generateRunnerId,
645
994
  generateRunnerToken,
995
+ generateSiteKeys,
646
996
  generateUserCode,
647
997
  hashSecret,
648
998
  normalizeUserCode,
649
999
  routeEndpoint,
650
- secretsMatch
1000
+ secretsMatch,
1001
+ signatureFrom,
1002
+ siteKeysFromEnv
651
1003
  };
652
1004
  //# sourceMappingURL=index.js.map