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

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