@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 +65 -11
- package/bin/keygen.mjs +21 -0
- package/dist/{chunk-HL6EYHQ7.js → chunk-MGJX6626.js} +229 -52
- package/dist/chunk-MGJX6626.js.map +1 -0
- package/dist/{handlers-D7lWfwno.d.ts → handlers-CF3yE-t2.d.ts} +27 -4
- package/dist/index.d.ts +80 -12
- package/dist/index.js +191 -26
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +40 -8
- package/dist/next.js +8 -2
- package/dist/next.js.map +1 -1
- package/dist/{store-D23N6iiP.d.ts → store-gFEEN1Dt.d.ts} +72 -6
- package/dist/supabase/index.d.ts +1 -1
- package/dist/supabase/index.js +61 -17
- package/dist/supabase/index.js.map +1 -1
- package/package.json +8 -4
- package/supabase/migrations/20260809000000_byollm_runner.sql +22 -3
- package/dist/chunk-HL6EYHQ7.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
> [!WARNING]
|
|
2
|
-
> **Alpha (`0.1.0-alpha.
|
|
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 {
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
111
|
+
* @param auth - the signature and the exact bytes it covers
|
|
94
112
|
*/
|
|
95
|
-
async handle(endpoint, body,
|
|
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(
|
|
118
|
+
return this.#authed(auth, body, ClaimRequest, this.#claim.bind(this));
|
|
101
119
|
case "heartbeat":
|
|
102
120
|
return this.#authed(
|
|
103
|
-
|
|
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
|
-
|
|
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:
|
|
127
|
-
*
|
|
141
|
+
* Shared preamble for the four authenticated endpoints: verify the
|
|
142
|
+
* signature, reject a revoked runner, and parse the body.
|
|
128
143
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
144
|
+
* Authentication happens before schema validation so a stranger probing the
|
|
145
|
+
* endpoint learns nothing about the wire format.
|
|
131
146
|
*/
|
|
132
|
-
async #authed(
|
|
133
|
-
|
|
134
|
-
|
|
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.
|
|
152
|
+
const runner = await this.#store.getRunner(signature.data.runnerId);
|
|
137
153
|
if (!runner) {
|
|
138
|
-
return fail("unauthorized", "this runner
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
return
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
596
|
+
signatureFrom,
|
|
420
597
|
createFetchHandler
|
|
421
598
|
};
|
|
422
|
-
//# sourceMappingURL=chunk-
|
|
599
|
+
//# sourceMappingURL=chunk-MGJX6626.js.map
|