@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 +72 -11
- package/bin/keygen.mjs +21 -0
- package/dist/{chunk-HL6EYHQ7.js → chunk-4NIHWQAT.js} +253 -53
- package/dist/chunk-4NIHWQAT.js.map +1 -0
- package/dist/{handlers-D7lWfwno.d.ts → handlers-DgW0QNTf.d.ts} +27 -4
- package/dist/index.d.ts +176 -12
- package/dist/index.js +375 -23
- 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-Cj5b6A9j.d.ts} +140 -7
- package/dist/supabase/index.d.ts +1 -1
- package/dist/supabase/index.js +91 -24
- package/dist/supabase/index.js.map +1 -1
- package/package.json +7 -3
- 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.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 {
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
152
|
+
* @param auth - the signature and the exact bytes it covers
|
|
94
153
|
*/
|
|
95
|
-
async handle(endpoint, body,
|
|
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(
|
|
159
|
+
return this.#authed(auth, body, ClaimRequest, this.#claim.bind(this));
|
|
101
160
|
case "heartbeat":
|
|
102
161
|
return this.#authed(
|
|
103
|
-
|
|
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
|
-
|
|
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:
|
|
127
|
-
*
|
|
182
|
+
* Shared preamble for the four authenticated endpoints: verify the
|
|
183
|
+
* signature, reject a revoked runner, and parse the body.
|
|
128
184
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
185
|
+
* Authentication happens before schema validation so a stranger probing the
|
|
186
|
+
* endpoint learns nothing about the wire format.
|
|
131
187
|
*/
|
|
132
|
-
async #authed(
|
|
133
|
-
|
|
134
|
-
|
|
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.
|
|
193
|
+
const runner = await this.#store.getRunner(signature.data.runnerId);
|
|
137
194
|
if (!runner) {
|
|
138
|
-
return fail("unauthorized", "this runner
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
619
|
+
signatureFrom,
|
|
420
620
|
createFetchHandler
|
|
421
621
|
};
|
|
422
|
-
//# sourceMappingURL=chunk-
|
|
622
|
+
//# sourceMappingURL=chunk-4NIHWQAT.js.map
|