@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/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  > [!WARNING]
2
- > **Alpha (`0.1.0-alpha.3`) — under active development. Don't use this yet.**
2
+ > **Alpha (`0.1.0-alpha.5`) — under active development. Don't use this yet.**
3
3
  >
4
4
  > Install it deliberately: `npm install @byollm/server@alpha`.
5
5
  >
@@ -12,6 +12,26 @@
12
12
  > bare install resolves here too. This notice is the only guard — deliberately
13
13
  > not an npm deprecation, which would read as *abandoned* rather than *early*.
14
14
  > Ask for `@alpha` explicitly so your lockfile records that you meant to.
15
+ >
16
+ > **`alpha.5` breaks store adapters, and nothing else.** If you implement
17
+ > `JobStore` yourself, two changes are required: a new `adopt(args)` method
18
+ > (record a lease granted by an upstream this store does not own), and
19
+ > `CompleteArgs.runnerId` is replaced by `holder` — a discriminated union
20
+ > naming either a runner or a lease. Apps, daemons and the wire format are
21
+ > unaffected; `@byollm/conformance` will tell you if you missed one.
22
+ >
23
+ > **`alpha.4` broke every integration.** Three things changed for you:
24
+ >
25
+ > 1. **`siteKeys` is required.** Run `npx @byollm/server@alpha keygen` once,
26
+ > set `BYOLLM_SITE_KEYS`, and pass it to `ByollmApp` and `createHandler`.
27
+ > Once — not per deploy, never at startup.
28
+ > 2. **`createHandler` takes a function.** `next build` imports route modules
29
+ > with no secrets present, so a config object fails the build.
30
+ > 3. **Every paired runner re-pairs.** Bearer tokens are replaced by per-request
31
+ > signatures against a pinned device key, so old tokens authenticate nothing.
32
+ >
33
+ > Your store adapter is unaffected: payloads and results are sealed before they
34
+ > reach it, and `JobStore` did not change.
15
35
 
16
36
  # `@byollm/server`
17
37
 
@@ -30,28 +50,68 @@ npm install @byollm/server
30
50
  ```ts
31
51
  // app/api/byollm/[...route]/route.ts
32
52
  import { createHandler } from "@byollm/server/next";
33
- import { store } from "@/lib/byollm";
53
+ import { siteKeysFromEnv } from "@byollm/server";
54
+ import { getStore } from "@/lib/byollm";
34
55
 
35
- export const { POST } = createHandler({
36
- store,
56
+ export const { POST } = createHandler(() => ({
57
+ store: getStore(),
58
+ siteKeys: siteKeysFromEnv("BYOLLM_SITE_KEYS"),
37
59
  verificationUrl: "https://your-app.com/settings/runners",
38
- });
60
+ // Next serves this route under /api, so say where it is mounted. The
61
+ // handler matches the full path and will 404 without this.
62
+ basePath: "/api/byollm",
63
+ }));
39
64
  ```
40
65
 
66
+ **Pass a function, not an object.** `next build` imports every route module to
67
+ collect page data, in an environment that has no secrets. A config object is
68
+ constructed during that import, so the build fails on credentials it cannot
69
+ have. A function is not called until the first request.
70
+
71
+ Then pair against that same path — `byollm connect https://your-app.com/api`.
72
+ The daemon appends `/byollm/<endpoint>` to whatever origin it is given, so
73
+ connecting to the bare domain looks for `/byollm/claim` and finds nothing.
74
+ To serve at `/byollm` instead, put the route at `app/byollm/[...route]/route.ts`,
75
+ drop `basePath`, and pair against the bare domain.
76
+
41
77
  **2. Pick a store.**
42
78
 
43
79
  ```ts
44
80
  // lib/byollm.ts
45
- import { ByollmApp, MemoryStore } from "@byollm/server";
81
+ import { ByollmApp, MemoryStore, siteKeysFromEnv } from "@byollm/server";
46
82
 
47
- export const store = new MemoryStore();
48
- export const app = new ByollmApp({ store });
83
+ // Lazily, and memoized, for the same reason the mount takes a function: a
84
+ // module-scope `new` runs during `next build`.
85
+ let store: MemoryStore | undefined;
86
+ export function getStore(): MemoryStore {
87
+ return (store ??= new MemoryStore());
88
+ }
89
+
90
+ let app: ByollmApp | undefined;
91
+ export function getApp(): ByollmApp {
92
+ return (app ??= new ByollmApp({
93
+ store: getStore(),
94
+ siteKeys: siteKeysFromEnv("BYOLLM_SITE_KEYS"),
95
+ }));
96
+ }
97
+ ```
98
+
99
+ Generate that identity once, and keep it:
100
+
101
+ ```bash
102
+ npx @byollm/server@alpha keygen # prints BYOLLM_SITE_KEYS=...
49
103
  ```
50
104
 
105
+ Once, not per deploy and never at startup — a daemon pins this identity when
106
+ its owner approves the pairing, and regenerating it means every paired machine
107
+ must pair again. Generating at startup fails only under horizontal scale: each
108
+ instance would have a different identity, and a daemon would be refused by
109
+ whichever one it did not pair with.
110
+
51
111
  **3. Enqueue.**
52
112
 
53
113
  ```ts
54
- const job = await app.enqueue({
114
+ const job = await getApp().enqueue({
55
115
  kind: "llm.generate",
56
116
  audience: "self", // this user's own machine only — the default
57
117
  owner: userId,
@@ -75,7 +135,7 @@ types the code their daemon showed them:
75
135
 
76
136
  ```ts
77
137
  // The owner comes from YOUR session. A daemon can never assert who it is.
78
- const runner = await app.approvePairing({
138
+ const runner = await getApp().approvePairing({
79
139
  userCode: formData.get("code"),
80
140
  owner: session.userId,
81
141
  });
@@ -103,6 +163,7 @@ import {
103
163
  const store = supabaseStore({ client: serviceRoleClient });
104
164
  const app = new ByollmApp({
105
165
  store,
166
+ siteKeys: siteKeysFromEnv("BYOLLM_SITE_KEYS"),
106
167
  delivery: supabaseRealtimeDelivery(serviceRoleClient),
107
168
  });
108
169
  ```
@@ -123,7 +184,7 @@ and `untrusted` is derived from the audience — you cannot mark volunteer
123
184
  output as first-party:
124
185
 
125
186
  ```ts
126
- const { outcome, provenance } = await app.result(jobId);
187
+ const { outcome, provenance } = await getApp().result(jobId);
127
188
  if (provenance?.untrusted) {
128
189
  // Do not render as trusted HTML. Do not feed to a privileged step.
129
190
  // Disclose where it came from.
package/bin/keygen.mjs ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx @byollm/server keygen` — make a site identity, once.
4
+ *
5
+ * Prints an env-file fragment. Deliberately not written to a file: key
6
+ * material that lands on disk by default tends to end up committed, and the
7
+ * one place it should live is wherever this deployment keeps its secrets.
8
+ */
9
+ import { formatSiteKeys, generateSiteKeys } from "../dist/index.js";
10
+
11
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
12
+ process.stdout.write(
13
+ "usage: npx @byollm/server keygen\n\n" +
14
+ "Generates this site's byollm identity and prints it as an env line.\n" +
15
+ "Run it once. Store the result as a secret. Regenerating it makes every\n" +
16
+ "daemon that has paired with this site pair again.\n",
17
+ );
18
+ process.exit(0);
19
+ }
20
+
21
+ process.stdout.write(formatSiteKeys(generateSiteKeys()));
@@ -16,7 +16,7 @@ function generateRunnerId() {
16
16
  return `runner_${randomUUID()}`;
17
17
  }
18
18
  function generateJobId() {
19
- return `job_${randomUUID()}`;
19
+ return randomUUID();
20
20
  }
21
21
  function generateUserCode() {
22
22
  const chars = [];
@@ -39,9 +39,18 @@ function secretsMatch(aHex, bHex) {
39
39
  if (aHex.length !== bHex.length) return false;
40
40
  return timingSafeEqual(Buffer.from(aHex, "hex"), Buffer.from(bHex, "hex"));
41
41
  }
42
+ var generateLeaseId = () => randomUUID();
42
43
 
43
44
  // src/handlers.ts
44
45
  import {
46
+ FetchRequest,
47
+ JobOutcome,
48
+ keyId as keyId2,
49
+ open as open2,
50
+ publicIdentityOf as publicIdentityOf2,
51
+ RequestSignature,
52
+ verifyRequest,
53
+ verifyPublicIdentity,
45
54
  ClaimRequest,
46
55
  ERROR_STATUS,
47
56
  HeartbeatRequest,
@@ -51,6 +60,49 @@ import {
51
60
  ResultRequest,
52
61
  provenanceFor
53
62
  } from "@byollm/protocol";
63
+
64
+ // src/reseal.ts
65
+ import {
66
+ ENVELOPE_MAX_AGE_MS,
67
+ keyId,
68
+ open,
69
+ publicIdentityOf,
70
+ seal
71
+ } from "@byollm/protocol";
72
+ async function resealForDevice(input) {
73
+ const senderKeyId = keyId(publicIdentityOf(input.siteKeys).identity);
74
+ const opened = await open({
75
+ envelope: input.job.envelope,
76
+ recipientKeys: input.siteKeys,
77
+ senderIdentityPublic: input.siteKeys.identityPublic,
78
+ expected: {
79
+ jobId: input.job.id,
80
+ senderKeyId,
81
+ recipientKeyId: senderKeyId,
82
+ direction: "payload"
83
+ }
84
+ });
85
+ if (!opened.ok) {
86
+ return { ok: false, reason: "unopenable" };
87
+ }
88
+ const envelope = await seal({
89
+ plaintext: opened.plaintext,
90
+ senderKeys: input.siteKeys,
91
+ recipientEncryptionPublic: input.device.encryption,
92
+ context: {
93
+ jobId: input.job.id,
94
+ senderKeyId,
95
+ recipientKeyId: keyId(input.device.identity),
96
+ // From the record, never recomputed from a fresh clock read — the
97
+ // envelope's own deadline is what the signature bound.
98
+ deadlineAt: input.job.createdAt + ENVELOPE_MAX_AGE_MS,
99
+ direction: "payload"
100
+ }
101
+ });
102
+ return { ok: true, envelope };
103
+ }
104
+
105
+ // src/handlers.ts
54
106
  var DEFAULTS = {
55
107
  leaseMs: 6e4,
56
108
  pairingTtlMs: 10 * 6e4,
@@ -77,8 +129,15 @@ var ByollmHandlers = class {
77
129
  #pairingTtlMs;
78
130
  #pollIntervalMs;
79
131
  #now;
132
+ #siteKeys;
80
133
  constructor(config) {
81
134
  this.#store = config.store;
135
+ if (!verifyPublicIdentity(publicIdentityOf2(config.siteKeys))) {
136
+ throw new Error(
137
+ "siteKeys are not internally consistent: the encryption key is not signed by the identity key. Generate a fresh pair with `npx @byollm/server keygen`."
138
+ );
139
+ }
140
+ this.#siteKeys = config.siteKeys;
82
141
  this.#verificationUrl = config.verificationUrl;
83
142
  this.#leaseMs = config.leaseMs ?? DEFAULTS.leaseMs;
84
143
  this.#pairingTtlMs = config.pairingTtlMs ?? DEFAULTS.pairingTtlMs;
@@ -90,32 +149,29 @@ var ByollmHandlers = class {
90
149
  *
91
150
  * @param endpoint - which of the five, already routed from the path
92
151
  * @param body - the parsed JSON request body, untrusted
93
- * @param bearer - the `Authorization: Bearer` value, if any
152
+ * @param auth - the signature and the exact bytes it covers
94
153
  */
95
- async handle(endpoint, body, bearer) {
154
+ async handle(endpoint, body, auth) {
96
155
  switch (endpoint) {
97
156
  case "pair":
98
157
  return this.#pair(body);
99
158
  case "claim":
100
- return this.#authed(bearer, body, ClaimRequest, this.#claim.bind(this));
159
+ return this.#authed(auth, body, ClaimRequest, this.#claim.bind(this));
101
160
  case "heartbeat":
102
161
  return this.#authed(
103
- bearer,
162
+ auth,
104
163
  body,
105
164
  HeartbeatRequest,
106
165
  this.#heartbeat.bind(this),
107
166
  { allowRevoked: true }
108
167
  );
168
+ case "fetch":
169
+ return this.#authed(auth, body, FetchRequest, this.#fetch.bind(this));
109
170
  case "result":
110
- return this.#authed(
111
- bearer,
112
- body,
113
- ResultRequest,
114
- this.#result.bind(this)
115
- );
171
+ return this.#authed(auth, body, ResultRequest, this.#result.bind(this));
116
172
  case "release":
117
173
  return this.#authed(
118
- bearer,
174
+ auth,
119
175
  body,
120
176
  ReleaseRequest,
121
177
  this.#release.bind(this)
@@ -123,19 +179,30 @@ var ByollmHandlers = class {
123
179
  }
124
180
  }
125
181
  /**
126
- * Shared preamble for the four authenticated endpoints: resolve the bearer
127
- * token to a runner, reject a revoked one, and parse the body.
182
+ * Shared preamble for the four authenticated endpoints: verify the
183
+ * signature, reject a revoked runner, and parse the body.
128
184
  *
129
- * The token→runner lookup happens before schema validation so a stranger
130
- * probing the endpoint learns nothing about the wire format.
185
+ * Authentication happens before schema validation so a stranger probing the
186
+ * endpoint learns nothing about the wire format.
131
187
  */
132
- async #authed(bearer, body, schema, run, options = {}) {
133
- if (bearer === void 0 || bearer.length === 0) {
134
- return fail("unauthorized", "a runner token is required");
188
+ async #authed(auth, body, schema, run, options = {}) {
189
+ const signature = RequestSignature.safeParse(auth.signature);
190
+ if (!signature.success) {
191
+ return fail("unauthorized", "this request is not signed");
135
192
  }
136
- const runner = await this.#store.getRunnerByTokenHash(hashSecret(bearer));
193
+ const runner = await this.#store.getRunner(signature.data.runnerId);
137
194
  if (!runner) {
138
- return fail("unauthorized", "this runner token is not recognised");
195
+ return fail("unauthorized", "this runner is not recognised");
196
+ }
197
+ const failure = verifyRequest({
198
+ identityPublic: runner.device.identity,
199
+ endpoint: auth.endpoint,
200
+ body: auth.rawBody,
201
+ signature: signature.data,
202
+ now: this.#now()
203
+ });
204
+ if (failure !== null) {
205
+ return fail("unauthorized", "this request's signature is not valid");
139
206
  }
140
207
  if (runner.revokedAt !== null && options.allowRevoked !== true) {
141
208
  return fail("revoked", "this runner has been revoked by its owner");
@@ -146,6 +213,32 @@ var ByollmHandlers = class {
146
213
  }
147
214
  return run(parsed.data, runner);
148
215
  }
216
+ /**
217
+ * Hand over the payload for a lease this runner holds — byollm_009 §6.
218
+ *
219
+ * The second half of claim-then-fetch. A claim answers with a stub, and the
220
+ * work itself is collected separately by the device that took it, because a
221
+ * payload can only be sealed once its recipient is known.
222
+ *
223
+ * Scoped to the lease, not the job: answering for whatever lease happens to
224
+ * exist would hand the work to a runner whose grant had already been
225
+ * superseded.
226
+ */
227
+ async #fetch(request, runner) {
228
+ const job = await this.#store.get(request.jobId);
229
+ if (!job || job.lease?.runnerId !== runner.id || job.lease.id !== request.leaseId) {
230
+ return fail("not-found", "no such lease on this job");
231
+ }
232
+ const resealed = await resealForDevice({
233
+ siteKeys: this.#siteKeys,
234
+ job: { id: job.id, envelope: job.envelope, createdAt: job.createdAt },
235
+ device: runner.device
236
+ });
237
+ if (!resealed.ok) {
238
+ return fail("server-error", "this job's payload could not be opened");
239
+ }
240
+ return ok({ envelope: resealed.envelope });
241
+ }
149
242
  // -- 1. pair --------------------------------------------------------------
150
243
  async #pair(body) {
151
244
  const parsed = PairRequest.safeParse(body);
@@ -158,7 +251,14 @@ var ByollmHandlers = class {
158
251
  const deviceCode = generateDeviceCode();
159
252
  const userCode = generateUserCode();
160
253
  const expiresAt = now + this.#pairingTtlMs;
254
+ if (!verifyPublicIdentity(request.device)) {
255
+ return fail(
256
+ "bad-request",
257
+ "the device's encryption key is not signed by the identity it was presented with"
258
+ );
259
+ }
161
260
  await this.#store.createPairing({
261
+ device: request.device,
162
262
  deviceCodeHash: hashSecret(deviceCode),
163
263
  userCode,
164
264
  state: "pending",
@@ -198,7 +298,10 @@ var ByollmHandlers = class {
198
298
  status: "approved",
199
299
  runnerToken: pairing.runnerTokenOnce,
200
300
  runnerId: pairing.runnerId,
201
- owner: pairing.owner
301
+ owner: pairing.owner,
302
+ // Only on approval: a pending or denied poll learns nothing, so an
303
+ // unapproved code cannot be used to enumerate a site's keys.
304
+ site: publicIdentityOf2(this.#siteKeys)
202
305
  };
203
306
  await this.#store.consumePairingToken(pairing.deviceCodeHash);
204
307
  return ok(response);
@@ -211,7 +314,7 @@ var ByollmHandlers = class {
211
314
  // -- 2. claim -------------------------------------------------------------
212
315
  async #claim(request, runner) {
213
316
  if (request.runnerId !== runner.id) {
214
- return fail("unauthorized", "runner id does not match the bearer token");
317
+ return fail("unauthorized", "runner id does not match the signing key");
215
318
  }
216
319
  const now = this.#now();
217
320
  const jobs = await this.#store.claim({
@@ -226,14 +329,24 @@ var ByollmHandlers = class {
226
329
  jobs: jobs.map((job) => ({
227
330
  id: job.id,
228
331
  kind: job.kind,
229
- payload: job.payload,
230
332
  audience: job.audience,
231
333
  owner: job.owner,
334
+ // Bucketed, not measured: an exact size is a stronger fingerprint
335
+ // than routing needs (byollm_009 §6).
336
+ sizeClass: job.sizeClass,
337
+ // Reserved for byollm_006; no job declares it yet.
338
+ streaming: false,
339
+ // The stub's deadline bounds how long a captured envelope is worth
340
+ // keeping, so it is always present — falling back to the TTL window
341
+ // when the app named no absolute one.
342
+ deadlineAt: job.deadlineAt ?? (job.claimableAt ?? now) + job.ttlMs,
232
343
  ...job.audienceAllow === void 0 ? {} : { audienceAllow: [...job.audienceAllow] },
233
- lease: job.lease ?? {
234
- runnerId: runner.id,
235
- expiresAt: now + this.#leaseMs
236
- }
344
+ // No fallback. A job returned from `claim` holds a lease by
345
+ // definition, and synthesising one here would hand the daemon a lease
346
+ // id the store has never heard of — every later release naming it
347
+ // would silently match nothing. A store that returns an unleased job
348
+ // has broken its contract, and this says so.
349
+ lease: leaseOf(job)
237
350
  })),
238
351
  leaseMs: this.#leaseMs
239
352
  };
@@ -242,7 +355,7 @@ var ByollmHandlers = class {
242
355
  // -- 3. heartbeat ---------------------------------------------------------
243
356
  async #heartbeat(request, runner) {
244
357
  if (request.runnerId !== runner.id) {
245
- return fail("unauthorized", "runner id does not match the bearer token");
358
+ return fail("unauthorized", "runner id does not match the signing key");
246
359
  }
247
360
  const now = this.#now();
248
361
  const revoked = runner.revokedAt !== null;
@@ -266,7 +379,7 @@ var ByollmHandlers = class {
266
379
  });
267
380
  const { renewed, lost } = await this.#store.renewLeases({
268
381
  runnerId: runner.id,
269
- jobIds: request.activeJobIds,
382
+ leases: request.activeLeases,
270
383
  leaseMs: this.#leaseMs,
271
384
  now
272
385
  });
@@ -283,11 +396,13 @@ var ByollmHandlers = class {
283
396
  // -- 4. result ------------------------------------------------------------
284
397
  async #result(request, runner) {
285
398
  if (request.runnerId !== runner.id) {
286
- return fail("unauthorized", "runner id does not match the bearer token");
399
+ return fail("unauthorized", "runner id does not match the signing key");
287
400
  }
288
401
  const now = this.#now();
289
402
  const job = await this.#store.get(request.jobId);
290
403
  if (!job) return fail("not-found", "unknown job");
404
+ const outcome = await this.#openResult(request, runner);
405
+ if (!outcome.ok) return outcome.failure;
291
406
  const provenance = provenanceFor({
292
407
  audience: job.audience,
293
408
  runnerId: runner.id,
@@ -297,8 +412,8 @@ var ByollmHandlers = class {
297
412
  });
298
413
  const { accepted, job: updated } = await this.#store.complete({
299
414
  jobId: request.jobId,
300
- runnerId: runner.id,
301
- outcome: request.outcome,
415
+ holder: { by: "runner", runnerId: runner.id },
416
+ outcome: outcome.value,
302
417
  provenance,
303
418
  now
304
419
  });
@@ -308,14 +423,57 @@ var ByollmHandlers = class {
308
423
  };
309
424
  return ok(response);
310
425
  }
426
+ /**
427
+ * Open a sealed result, or refuse it.
428
+ *
429
+ * The mirror of the daemon's `#openPayload`, and refuses for the same
430
+ * reason: an outcome that does not verify against the device's pinned key is
431
+ * an assertion by whoever relayed it, and storing it would let an
432
+ * intermediary write answers into the app.
433
+ *
434
+ * The clear-text `disposition` is checked here rather than trusted. It is on
435
+ * the wire so a relay can route without opening anything, which means the
436
+ * one thing it must not be is authoritative — a daemon that sealed an error
437
+ * and declared `ok` would otherwise have its declaration believed by
438
+ * everything upstream of this line.
439
+ */
440
+ async #openResult(request, runner) {
441
+ const refuse = (why) => ({ ok: false, failure: fail("bad-request", why) });
442
+ const opened = await open2({
443
+ envelope: request.envelope,
444
+ recipientKeys: this.#siteKeys,
445
+ senderIdentityPublic: runner.device.identity,
446
+ expected: {
447
+ jobId: request.jobId,
448
+ senderKeyId: keyId2(runner.device.identity),
449
+ recipientKeyId: keyId2(publicIdentityOf2(this.#siteKeys).identity),
450
+ direction: "result"
451
+ }
452
+ });
453
+ if (!opened.ok) {
454
+ return refuse("the result did not verify as coming from this device");
455
+ }
456
+ let parsed;
457
+ try {
458
+ parsed = JSON.parse(opened.plaintext);
459
+ } catch {
460
+ return refuse("the sealed result was not valid JSON");
461
+ }
462
+ const outcome = JobOutcome.safeParse(parsed);
463
+ if (!outcome.success) return refuse("the sealed result was not an outcome");
464
+ if (outcome.data.outcome !== request.disposition) {
465
+ return refuse("the declared disposition is not the one that was sealed");
466
+ }
467
+ return { ok: true, value: outcome.data };
468
+ }
311
469
  // -- 5. release -----------------------------------------------------------
312
470
  async #release(request, runner) {
313
471
  if (request.runnerId !== runner.id) {
314
- return fail("unauthorized", "runner id does not match the bearer token");
472
+ return fail("unauthorized", "runner id does not match the signing key");
315
473
  }
316
474
  const released = await this.#store.release({
317
475
  runnerId: runner.id,
318
- jobIds: request.jobIds,
476
+ leases: request.leases,
319
477
  reason: request.reason,
320
478
  now: this.#now()
321
479
  });
@@ -324,22 +482,54 @@ var ByollmHandlers = class {
324
482
  }
325
483
  };
326
484
  var SERVED_PROTOCOL_VERSION = PROTOCOL_VERSION;
485
+ function leaseOf(job) {
486
+ if (!job.lease) {
487
+ throw new Error(
488
+ `store returned job ${job.id} from claim with no lease \u2014 the store contract requires a claimed job to hold one`
489
+ );
490
+ }
491
+ return job.lease;
492
+ }
327
493
 
328
494
  // src/http.ts
329
- import { ENDPOINTS, PROTOCOL_PREFIX } from "@byollm/protocol";
495
+ import {
496
+ ENDPOINTS,
497
+ ERROR_STATUS as ERROR_STATUS2,
498
+ PROTOCOL_PREFIX,
499
+ checkProtocolVersion
500
+ } from "@byollm/protocol";
330
501
  var MAX_BODY_BYTES = 8 * 1024 * 1024;
331
- function routeEndpoint(pathname) {
332
- const index = pathname.lastIndexOf("/");
333
- const last = index === -1 ? pathname : pathname.slice(index + 1);
334
- return ENDPOINTS.includes(last) ? last : null;
502
+ function normalizeBasePath(basePath) {
503
+ const trimmed = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
504
+ if (!trimmed.startsWith("/")) {
505
+ throw new Error(`basePath must start with "/": got ${basePath}`);
506
+ }
507
+ if (trimmed.includes("//") || /[?#*]/.test(trimmed)) {
508
+ throw new Error(`basePath must be a plain path: got ${basePath}`);
509
+ }
510
+ return trimmed;
511
+ }
512
+ function routeEndpoint(pathname, basePath = PROTOCOL_PREFIX) {
513
+ const base = normalizeBasePath(basePath);
514
+ const path = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
515
+ if (!path.startsWith(`${base}/`)) return null;
516
+ const rest = path.slice(base.length + 1);
517
+ return ENDPOINTS.includes(rest) ? rest : null;
335
518
  }
336
- function bearerFrom(header) {
337
- if (!header) return void 0;
338
- const match = /^Bearer[ ]+(.+)$/i.exec(header.trim());
339
- return match?.[1];
519
+ function signatureFrom(headers) {
520
+ const runnerId = headers.get("x-byollm-runner");
521
+ const rawIssuedAt = headers.get("x-byollm-issued-at");
522
+ const signature = headers.get("x-byollm-signature");
523
+ if (runnerId === null || signature === null || rawIssuedAt === null) {
524
+ return void 0;
525
+ }
526
+ const issuedAt = Number(rawIssuedAt);
527
+ if (!Number.isFinite(issuedAt)) return void 0;
528
+ return { runnerId, issuedAt, signature };
340
529
  }
341
530
  function createFetchHandler(config) {
342
531
  const handlers = new ByollmHandlers(config);
532
+ const basePath = normalizeBasePath(config.basePath ?? PROTOCOL_PREFIX);
343
533
  return async function handle(request) {
344
534
  if (request.method !== "POST") {
345
535
  return json(405, {
@@ -347,11 +537,11 @@ function createFetchHandler(config) {
347
537
  message: "protocol endpoints accept POST only"
348
538
  });
349
539
  }
350
- const endpoint = routeEndpoint(new URL(request.url).pathname);
540
+ const endpoint = routeEndpoint(new URL(request.url).pathname, basePath);
351
541
  if (endpoint === null) {
352
542
  return json(404, {
353
543
  error: "not-found",
354
- message: `not a ${PROTOCOL_PREFIX} endpoint`
544
+ message: `not a ${basePath} endpoint`
355
545
  });
356
546
  }
357
547
  const declared = request.headers.get("content-length");
@@ -362,8 +552,10 @@ function createFetchHandler(config) {
362
552
  });
363
553
  }
364
554
  let body;
555
+ let rawBody;
365
556
  try {
366
- const text = await request.text();
557
+ rawBody = await request.text();
558
+ const text = rawBody;
367
559
  if (text.length > MAX_BODY_BYTES) {
368
560
  return json(400, {
369
561
  error: "bad-request",
@@ -377,11 +569,17 @@ function createFetchHandler(config) {
377
569
  message: "request body is not valid JSON"
378
570
  });
379
571
  }
380
- const result = await handlers.handle(
572
+ const refusal = checkProtocolVersion(body);
573
+ if (refusal) {
574
+ return json(ERROR_STATUS2[refusal.error], refusal);
575
+ }
576
+ const result = await handlers.handle(endpoint, body, {
381
577
  endpoint,
382
- body,
383
- bearerFrom(request.headers.get("authorization"))
384
- );
578
+ // The bytes as received. Re-serialising the parsed object would verify
579
+ // a signature over something the sender never sent.
580
+ rawBody,
581
+ signature: signatureFrom(request.headers)
582
+ });
385
583
  const headers = {
386
584
  "content-type": "application/json",
387
585
  "cache-control": "no-store"
@@ -413,10 +611,12 @@ export {
413
611
  generateUserCode,
414
612
  hashSecret,
415
613
  secretsMatch,
614
+ generateLeaseId,
615
+ resealForDevice,
416
616
  ByollmHandlers,
417
617
  SERVED_PROTOCOL_VERSION,
418
618
  routeEndpoint,
419
- bearerFrom,
619
+ signatureFrom,
420
620
  createFetchHandler
421
621
  };
422
- //# sourceMappingURL=chunk-HL6EYHQ7.js.map
622
+ //# sourceMappingURL=chunk-4NIHWQAT.js.map