@byollm/server 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +151 -12
- package/bin/keygen.mjs +21 -0
- package/dist/chunk-K5E6JS5A.js +655 -0
- package/dist/chunk-K5E6JS5A.js.map +1 -0
- package/dist/{handlers-D7lWfwno.d.ts → handlers-BJYm2kdq.d.ts} +27 -4
- package/dist/index.d.ts +221 -17
- package/dist/index.js +549 -44
- 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-Dno2fnHH.d.ts +436 -0
- package/dist/supabase/index.d.ts +1 -1
- package/dist/supabase/index.js +115 -42
- package/dist/supabase/index.js.map +1 -1
- package/package.json +7 -3
- package/supabase/migrations/20260809000000_byollm_runner.sql +22 -3
- package/supabase/migrations/20260819000000_drop_runner_token.sql +87 -0
- package/supabase/migrations/20260819010000_completed_by_lease_id.sql +25 -0
- package/supabase/migrations/20260821000000_rename_collected.sql +91 -0
- package/dist/chunk-HL6EYHQ7.js +0 -422
- package/dist/chunk-HL6EYHQ7.js.map +0 -1
- package/dist/store-D23N6iiP.d.ts +0 -255
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// src/ids.ts
|
|
2
|
+
import {
|
|
3
|
+
createHash,
|
|
4
|
+
randomBytes,
|
|
5
|
+
randomUUID,
|
|
6
|
+
timingSafeEqual
|
|
7
|
+
} from "crypto";
|
|
8
|
+
var USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRTWXYZ2346789";
|
|
9
|
+
function generateDeviceCode() {
|
|
10
|
+
return randomBytes(32).toString("base64url");
|
|
11
|
+
}
|
|
12
|
+
function generateRunnerId() {
|
|
13
|
+
return `runner_${randomUUID()}`;
|
|
14
|
+
}
|
|
15
|
+
function generateJobId() {
|
|
16
|
+
return randomUUID();
|
|
17
|
+
}
|
|
18
|
+
function generateUserCode() {
|
|
19
|
+
const chars = [];
|
|
20
|
+
while (chars.length < 8) {
|
|
21
|
+
for (const byte of randomBytes(16)) {
|
|
22
|
+
const limit = 256 - 256 % USER_CODE_ALPHABET.length;
|
|
23
|
+
if (byte >= limit) continue;
|
|
24
|
+
const symbol = USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length];
|
|
25
|
+
if (symbol === void 0) continue;
|
|
26
|
+
chars.push(symbol);
|
|
27
|
+
if (chars.length === 8) break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return `${chars.slice(0, 4).join("")}-${chars.slice(4).join("")}`;
|
|
31
|
+
}
|
|
32
|
+
function hashSecret(secret) {
|
|
33
|
+
return createHash("sha256").update(secret, "utf8").digest("hex");
|
|
34
|
+
}
|
|
35
|
+
function secretsMatch(aHex, bHex) {
|
|
36
|
+
if (aHex.length !== bHex.length) return false;
|
|
37
|
+
return timingSafeEqual(Buffer.from(aHex, "hex"), Buffer.from(bHex, "hex"));
|
|
38
|
+
}
|
|
39
|
+
var generateLeaseId = () => randomUUID();
|
|
40
|
+
|
|
41
|
+
// src/handlers.ts
|
|
42
|
+
import {
|
|
43
|
+
FetchRequest,
|
|
44
|
+
SealedOutcome,
|
|
45
|
+
keyId as keyId2,
|
|
46
|
+
open as open2,
|
|
47
|
+
publicIdentityOf as publicIdentityOf2,
|
|
48
|
+
RequestSignature,
|
|
49
|
+
verifyRequest,
|
|
50
|
+
verifyPublicIdentity,
|
|
51
|
+
ClaimRequest,
|
|
52
|
+
ERROR_STATUS,
|
|
53
|
+
HeartbeatRequest,
|
|
54
|
+
PairRequest,
|
|
55
|
+
PROTOCOL_VERSION,
|
|
56
|
+
ReleaseRequest,
|
|
57
|
+
ResultRequest,
|
|
58
|
+
provenanceFor
|
|
59
|
+
} from "@byollm/protocol";
|
|
60
|
+
|
|
61
|
+
// src/reseal.ts
|
|
62
|
+
import {
|
|
63
|
+
ENVELOPE_MAX_AGE_MS,
|
|
64
|
+
keyId,
|
|
65
|
+
open,
|
|
66
|
+
publicIdentityOf,
|
|
67
|
+
seal
|
|
68
|
+
} from "@byollm/protocol";
|
|
69
|
+
async function resealForDevice(input) {
|
|
70
|
+
const senderKeyId = keyId(publicIdentityOf(input.siteKeys).identity);
|
|
71
|
+
const opened = await open({
|
|
72
|
+
envelope: input.job.envelope,
|
|
73
|
+
recipientKeys: input.siteKeys,
|
|
74
|
+
senderIdentityPublic: input.siteKeys.identityPublic,
|
|
75
|
+
expected: {
|
|
76
|
+
jobId: input.job.id,
|
|
77
|
+
senderKeyId,
|
|
78
|
+
recipientKeyId: senderKeyId,
|
|
79
|
+
direction: "payload"
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
if (!opened.ok) {
|
|
83
|
+
return { ok: false, reason: "unopenable" };
|
|
84
|
+
}
|
|
85
|
+
const envelope = await seal({
|
|
86
|
+
plaintext: opened.plaintext,
|
|
87
|
+
senderKeys: input.siteKeys,
|
|
88
|
+
recipientEncryptionPublic: input.device.encryption,
|
|
89
|
+
context: {
|
|
90
|
+
jobId: input.job.id,
|
|
91
|
+
senderKeyId,
|
|
92
|
+
recipientKeyId: keyId(input.device.identity),
|
|
93
|
+
// From the record, never recomputed from a fresh clock read — the
|
|
94
|
+
// envelope's own deadline is what the signature bound.
|
|
95
|
+
deadlineAt: input.job.createdAt + ENVELOPE_MAX_AGE_MS,
|
|
96
|
+
direction: "payload"
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return { ok: true, envelope };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/records.ts
|
|
103
|
+
function deadlineFor(job, now) {
|
|
104
|
+
return job.deadlineAt ?? (job.claimableAt ?? now) + job.ttlMs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/handlers.ts
|
|
108
|
+
var DEFAULTS = {
|
|
109
|
+
leaseMs: 6e4,
|
|
110
|
+
pairingTtlMs: 10 * 6e4,
|
|
111
|
+
pollIntervalMs: 2e3
|
|
112
|
+
};
|
|
113
|
+
function fail(error, message, retryAfterSeconds) {
|
|
114
|
+
return {
|
|
115
|
+
status: ERROR_STATUS[error],
|
|
116
|
+
body: {
|
|
117
|
+
error,
|
|
118
|
+
message,
|
|
119
|
+
...retryAfterSeconds === void 0 ? {} : { retryAfter: retryAfterSeconds }
|
|
120
|
+
},
|
|
121
|
+
...retryAfterSeconds === void 0 ? {} : { retryAfterSeconds }
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function ok(body) {
|
|
125
|
+
return { status: 200, body };
|
|
126
|
+
}
|
|
127
|
+
var ByollmHandlers = class {
|
|
128
|
+
#store;
|
|
129
|
+
#verificationUrl;
|
|
130
|
+
#leaseMs;
|
|
131
|
+
#pairingTtlMs;
|
|
132
|
+
#pollIntervalMs;
|
|
133
|
+
#now;
|
|
134
|
+
#siteKeys;
|
|
135
|
+
/** This site's identity key id — Amendment A's `stub.site`. Derived once. */
|
|
136
|
+
#siteKeyId;
|
|
137
|
+
constructor(config) {
|
|
138
|
+
this.#store = config.store;
|
|
139
|
+
if (!verifyPublicIdentity(publicIdentityOf2(config.siteKeys))) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
"siteKeys are not internally consistent: the encryption key is not signed by the identity key. Generate a fresh pair with `npx @byollm/server keygen`."
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
this.#siteKeys = config.siteKeys;
|
|
145
|
+
this.#siteKeyId = keyId2(publicIdentityOf2(config.siteKeys).identity);
|
|
146
|
+
this.#verificationUrl = config.verificationUrl;
|
|
147
|
+
this.#leaseMs = config.leaseMs ?? DEFAULTS.leaseMs;
|
|
148
|
+
this.#pairingTtlMs = config.pairingTtlMs ?? DEFAULTS.pairingTtlMs;
|
|
149
|
+
this.#pollIntervalMs = config.pollIntervalMs ?? DEFAULTS.pollIntervalMs;
|
|
150
|
+
this.#now = config.now ?? Date.now;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Dispatch one protocol call.
|
|
154
|
+
*
|
|
155
|
+
* @param endpoint - which of the five, already routed from the path
|
|
156
|
+
* @param body - the parsed JSON request body, untrusted
|
|
157
|
+
* @param auth - the signature and the exact bytes it covers
|
|
158
|
+
*/
|
|
159
|
+
async handle(endpoint, body, auth) {
|
|
160
|
+
switch (endpoint) {
|
|
161
|
+
case "pair":
|
|
162
|
+
return this.#pair(body);
|
|
163
|
+
case "claim":
|
|
164
|
+
return this.#authed(auth, body, ClaimRequest, this.#claim.bind(this));
|
|
165
|
+
case "heartbeat":
|
|
166
|
+
return this.#authed(
|
|
167
|
+
auth,
|
|
168
|
+
body,
|
|
169
|
+
HeartbeatRequest,
|
|
170
|
+
this.#heartbeat.bind(this)
|
|
171
|
+
);
|
|
172
|
+
case "fetch":
|
|
173
|
+
return this.#authed(auth, body, FetchRequest, this.#fetch.bind(this));
|
|
174
|
+
case "result":
|
|
175
|
+
return this.#authed(auth, body, ResultRequest, this.#result.bind(this));
|
|
176
|
+
case "release":
|
|
177
|
+
return this.#authed(
|
|
178
|
+
auth,
|
|
179
|
+
body,
|
|
180
|
+
ReleaseRequest,
|
|
181
|
+
this.#release.bind(this)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Shared preamble for the four authenticated endpoints: verify the
|
|
187
|
+
* signature, reject a revoked runner, and parse the body.
|
|
188
|
+
*
|
|
189
|
+
* Authentication happens before schema validation so a stranger probing the
|
|
190
|
+
* endpoint learns nothing about the wire format.
|
|
191
|
+
*/
|
|
192
|
+
async #authed(auth, body, schema, run, options = {}) {
|
|
193
|
+
const signature = RequestSignature.safeParse(auth.signature);
|
|
194
|
+
if (!signature.success) {
|
|
195
|
+
return fail("unauthorized", "this request is not signed");
|
|
196
|
+
}
|
|
197
|
+
const runner = await this.#store.getRunner(signature.data.runnerId);
|
|
198
|
+
if (!runner) {
|
|
199
|
+
return fail("unauthorized", "this runner is not recognised");
|
|
200
|
+
}
|
|
201
|
+
const failure = verifyRequest({
|
|
202
|
+
identityPublic: runner.device.identity,
|
|
203
|
+
endpoint: auth.endpoint,
|
|
204
|
+
body: auth.rawBody,
|
|
205
|
+
signature: signature.data,
|
|
206
|
+
now: this.#now()
|
|
207
|
+
});
|
|
208
|
+
if (failure !== null) {
|
|
209
|
+
return fail("unauthorized", "this request's signature is not valid");
|
|
210
|
+
}
|
|
211
|
+
if (runner.revokedAt !== null && options.allowRevoked !== true) {
|
|
212
|
+
return fail("revoked", "this runner has been revoked by its owner");
|
|
213
|
+
}
|
|
214
|
+
const parsed = schema.safeParse(body);
|
|
215
|
+
if (!parsed.success || parsed.data === void 0) {
|
|
216
|
+
return fail("bad-request", "request body failed schema validation");
|
|
217
|
+
}
|
|
218
|
+
return run(parsed.data, runner);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Hand over the payload for a lease this runner holds — byollm_009 §6.
|
|
222
|
+
*
|
|
223
|
+
* The second half of claim-then-fetch. A claim answers with a stub, and the
|
|
224
|
+
* work itself is collected separately by the device that took it, because a
|
|
225
|
+
* payload can only be sealed once its recipient is known.
|
|
226
|
+
*
|
|
227
|
+
* Scoped to the lease, not the job: answering for whatever lease happens to
|
|
228
|
+
* exist would hand the work to a runner whose grant had already been
|
|
229
|
+
* superseded.
|
|
230
|
+
*/
|
|
231
|
+
async #fetch(request, runner) {
|
|
232
|
+
const job = await this.#store.get(request.jobId);
|
|
233
|
+
if (!job || job.lease?.runnerId !== runner.id || job.lease.id !== request.leaseId) {
|
|
234
|
+
return fail("not-found", "no such lease on this job");
|
|
235
|
+
}
|
|
236
|
+
const resealed = await resealForDevice({
|
|
237
|
+
siteKeys: this.#siteKeys,
|
|
238
|
+
job: { id: job.id, envelope: job.envelope, createdAt: job.createdAt },
|
|
239
|
+
device: runner.device
|
|
240
|
+
});
|
|
241
|
+
if (!resealed.ok) {
|
|
242
|
+
return fail("server-error", "this job's payload could not be opened");
|
|
243
|
+
}
|
|
244
|
+
return ok({ envelope: resealed.envelope });
|
|
245
|
+
}
|
|
246
|
+
// -- 1. pair --------------------------------------------------------------
|
|
247
|
+
async #pair(body) {
|
|
248
|
+
const parsed = PairRequest.safeParse(body);
|
|
249
|
+
if (!parsed.success) {
|
|
250
|
+
return fail("bad-request", "pair request failed schema validation");
|
|
251
|
+
}
|
|
252
|
+
const request = parsed.data;
|
|
253
|
+
const now = this.#now();
|
|
254
|
+
if (request.action === "start") {
|
|
255
|
+
const deviceCode = generateDeviceCode();
|
|
256
|
+
const userCode = generateUserCode();
|
|
257
|
+
const expiresAt = now + this.#pairingTtlMs;
|
|
258
|
+
if (!verifyPublicIdentity(request.device)) {
|
|
259
|
+
return fail(
|
|
260
|
+
"bad-request",
|
|
261
|
+
"the device's encryption key is not signed by the identity it was presented with"
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
await this.#store.createPairing({
|
|
265
|
+
device: request.device,
|
|
266
|
+
deviceCodeHash: hashSecret(deviceCode),
|
|
267
|
+
userCode,
|
|
268
|
+
state: "pending",
|
|
269
|
+
owner: null,
|
|
270
|
+
runnerId: null,
|
|
271
|
+
collected: false,
|
|
272
|
+
label: request.daemon.label,
|
|
273
|
+
platform: request.daemon.platform,
|
|
274
|
+
daemonVersion: request.daemon.version,
|
|
275
|
+
capabilities: request.capabilities,
|
|
276
|
+
expiresAt,
|
|
277
|
+
createdAt: now
|
|
278
|
+
});
|
|
279
|
+
const response = {
|
|
280
|
+
deviceCode,
|
|
281
|
+
userCode,
|
|
282
|
+
verificationUrl: this.#verificationUrl,
|
|
283
|
+
expiresAt,
|
|
284
|
+
pollIntervalMs: this.#pollIntervalMs
|
|
285
|
+
};
|
|
286
|
+
return ok(response);
|
|
287
|
+
}
|
|
288
|
+
const pairing = await this.#store.getPairingByDeviceCodeHash(
|
|
289
|
+
hashSecret(request.deviceCode)
|
|
290
|
+
);
|
|
291
|
+
if (!pairing) {
|
|
292
|
+
return fail("not-found", "unknown device code");
|
|
293
|
+
}
|
|
294
|
+
if (pairing.state === "denied") {
|
|
295
|
+
return ok({ status: "denied" });
|
|
296
|
+
}
|
|
297
|
+
if (pairing.expiresAt <= now && pairing.state === "pending") {
|
|
298
|
+
return ok({ status: "expired" });
|
|
299
|
+
}
|
|
300
|
+
if (pairing.state === "approved" && !pairing.collected && pairing.runnerId !== null && pairing.owner !== null) {
|
|
301
|
+
const response = {
|
|
302
|
+
status: "approved",
|
|
303
|
+
runnerId: pairing.runnerId,
|
|
304
|
+
owner: pairing.owner,
|
|
305
|
+
// Only on approval: a pending or denied poll learns nothing, so an
|
|
306
|
+
// unapproved code cannot be used to enumerate a site's keys.
|
|
307
|
+
//
|
|
308
|
+
// One entry, because a direct site *is* one site — the same shape a
|
|
309
|
+
// hub answers with rather than a special case (cloud_009 §5). The
|
|
310
|
+
// daemon's lookup is one map read on every lane, which is what keeps
|
|
311
|
+
// the two lanes one protocol.
|
|
312
|
+
sites: { [this.#siteKeyId]: publicIdentityOf2(this.#siteKeys) }
|
|
313
|
+
};
|
|
314
|
+
await this.#store.consumePairingToken(pairing.deviceCodeHash);
|
|
315
|
+
return ok(response);
|
|
316
|
+
}
|
|
317
|
+
if (pairing.state === "approved") {
|
|
318
|
+
return fail("not-found", "this pairing has already been collected");
|
|
319
|
+
}
|
|
320
|
+
return ok({ status: "pending" });
|
|
321
|
+
}
|
|
322
|
+
// -- 2. claim -------------------------------------------------------------
|
|
323
|
+
async #claim(request, runner) {
|
|
324
|
+
if (request.runnerId !== runner.id) {
|
|
325
|
+
return fail("unauthorized", "runner id does not match the signing key");
|
|
326
|
+
}
|
|
327
|
+
const now = this.#now();
|
|
328
|
+
const jobs = await this.#store.claim({
|
|
329
|
+
runnerId: runner.id,
|
|
330
|
+
runnerOwner: runner.owner,
|
|
331
|
+
capabilities: request.capabilities,
|
|
332
|
+
max: request.max,
|
|
333
|
+
leaseMs: this.#leaseMs,
|
|
334
|
+
now
|
|
335
|
+
});
|
|
336
|
+
const response = {
|
|
337
|
+
jobs: jobs.map((job) => ({
|
|
338
|
+
id: job.id,
|
|
339
|
+
kind: job.kind,
|
|
340
|
+
audience: job.audience,
|
|
341
|
+
owner: job.owner,
|
|
342
|
+
// This site, named by its identity key id — Amendment A §A.3. The
|
|
343
|
+
// daemon pinned this exact value at pairing, so it can check the stub
|
|
344
|
+
// against the envelope it later opens rather than taking our word for
|
|
345
|
+
// which site sent it. On this plane that is redundant, which is the
|
|
346
|
+
// point: the direct and relayed stubs are the same shape, and a daemon
|
|
347
|
+
// serving both cannot tell which upstream it is talking to.
|
|
348
|
+
site: this.#siteKeyId,
|
|
349
|
+
// Bucketed, not measured: an exact size is a stronger fingerprint
|
|
350
|
+
// than routing needs (byollm_009 §6).
|
|
351
|
+
sizeClass: job.sizeClass,
|
|
352
|
+
// Reserved for byollm_006; no job declares it yet.
|
|
353
|
+
streaming: false,
|
|
354
|
+
// The stub's deadline bounds how long a captured envelope is worth
|
|
355
|
+
// keeping, so it is always present — falling back to the TTL window
|
|
356
|
+
// when the app named no absolute one.
|
|
357
|
+
deadlineAt: deadlineFor(job, now),
|
|
358
|
+
// `audienceAllow` is not sent — cloud_008 §0.2. The list stays on
|
|
359
|
+
// `JobRecord`, where `claim` already filtered candidates with it; the
|
|
360
|
+
// daemon's own allowlist is what decides `named` (byollm_001 Rev 1
|
|
361
|
+
// §B) and always was.
|
|
362
|
+
//
|
|
363
|
+
// Removing it from `JobStub` did **not** make this line a type error.
|
|
364
|
+
// A conditional spread is not excess-property-checked, so the field
|
|
365
|
+
// would have gone on being sent to a daemon whose `.strict()` parse
|
|
366
|
+
// now rejects the entire claim response — every daemon on the version
|
|
367
|
+
// pair, refusing all work, for a field nobody read. Worth stating
|
|
368
|
+
// where it happened: the schema is the contract, and the compiler
|
|
369
|
+
// does not enforce it through a spread.
|
|
370
|
+
// No fallback. A job returned from `claim` holds a lease by
|
|
371
|
+
// definition, and synthesising one here would hand the daemon a lease
|
|
372
|
+
// id the store has never heard of — every later release naming it
|
|
373
|
+
// would silently match nothing. A store that returns an unleased job
|
|
374
|
+
// has broken its contract, and this says so.
|
|
375
|
+
lease: leaseOf(job)
|
|
376
|
+
})),
|
|
377
|
+
leaseMs: this.#leaseMs
|
|
378
|
+
};
|
|
379
|
+
return ok(response);
|
|
380
|
+
}
|
|
381
|
+
// -- 3. heartbeat ---------------------------------------------------------
|
|
382
|
+
async #heartbeat(request, runner) {
|
|
383
|
+
if (request.runnerId !== runner.id) {
|
|
384
|
+
return fail("unauthorized", "runner id does not match the signing key");
|
|
385
|
+
}
|
|
386
|
+
const now = this.#now();
|
|
387
|
+
await this.#store.touchRunner({
|
|
388
|
+
runnerId: runner.id,
|
|
389
|
+
capabilities: request.capabilities,
|
|
390
|
+
daemonVersion: request.daemonVersion,
|
|
391
|
+
paused: request.paused,
|
|
392
|
+
now
|
|
393
|
+
});
|
|
394
|
+
const { lost } = await this.#store.renewLeases({
|
|
395
|
+
runnerId: runner.id,
|
|
396
|
+
leases: request.activeLeases,
|
|
397
|
+
leaseMs: this.#leaseMs,
|
|
398
|
+
now
|
|
399
|
+
});
|
|
400
|
+
const cancel = await this.#store.listCancelRequests(runner.id);
|
|
401
|
+
const response = {
|
|
402
|
+
sites: { [this.#siteKeyId]: publicIdentityOf2(this.#siteKeys) },
|
|
403
|
+
// A direct site has no disclosure of its own to go stale: consent to it
|
|
404
|
+
// *is* the pairing, and withdrawing it empties the set above.
|
|
405
|
+
awaitingConsent: [],
|
|
406
|
+
cancel: [...cancel],
|
|
407
|
+
lost: [...lost],
|
|
408
|
+
serverTime: now
|
|
409
|
+
};
|
|
410
|
+
return ok(response);
|
|
411
|
+
}
|
|
412
|
+
// -- 4. result ------------------------------------------------------------
|
|
413
|
+
async #result(request, runner) {
|
|
414
|
+
if (request.runnerId !== runner.id) {
|
|
415
|
+
return fail("unauthorized", "runner id does not match the signing key");
|
|
416
|
+
}
|
|
417
|
+
const now = this.#now();
|
|
418
|
+
const job = await this.#store.get(request.jobId);
|
|
419
|
+
if (!job) return fail("not-found", "unknown job");
|
|
420
|
+
const outcome = await this.#openResult(request, runner);
|
|
421
|
+
if (!outcome.ok) return outcome.failure;
|
|
422
|
+
const provenance = provenanceFor({
|
|
423
|
+
audience: job.audience,
|
|
424
|
+
runnerId: runner.id,
|
|
425
|
+
runnerOwner: runner.owner,
|
|
426
|
+
// From the envelope the device signed, not from the request beside it
|
|
427
|
+
// — cloud_008 §2.5. A daemon can no longer seal one answer and declare
|
|
428
|
+
// it came from a different model.
|
|
429
|
+
backendClass: outcome.value.ran.backendClass,
|
|
430
|
+
model: outcome.value.ran.model
|
|
431
|
+
});
|
|
432
|
+
const {
|
|
433
|
+
accepted,
|
|
434
|
+
duplicate,
|
|
435
|
+
job: updated
|
|
436
|
+
} = await this.#store.complete({
|
|
437
|
+
jobId: request.jobId,
|
|
438
|
+
// Who is asking, for the duplicate answer only — §3.6. Authorisation
|
|
439
|
+
// is `holder`, below, and still is.
|
|
440
|
+
runnerId: runner.id,
|
|
441
|
+
// The grant, not the runner — cloud_008 §1.4a. `CompleteHolder`'s own
|
|
442
|
+
// docstring already called the lease "the more exact check anyway";
|
|
443
|
+
// this plane simply had no lease id to give it until now.
|
|
444
|
+
holder: { by: "lease", leaseId: request.leaseId },
|
|
445
|
+
outcome: outcome.value.outcome,
|
|
446
|
+
provenance,
|
|
447
|
+
now
|
|
448
|
+
});
|
|
449
|
+
const response = {
|
|
450
|
+
accepted,
|
|
451
|
+
// Only when true — cloud_008 §3.6. Absent means "not a duplicate", and
|
|
452
|
+
// an optional field that is always present is a required one wearing a
|
|
453
|
+
// question mark.
|
|
454
|
+
...duplicate === true ? { duplicate: true } : {},
|
|
455
|
+
state: updated?.state ?? job.state
|
|
456
|
+
};
|
|
457
|
+
return ok(response);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Open a sealed result, or refuse it.
|
|
461
|
+
*
|
|
462
|
+
* The mirror of the daemon's `#openPayload`, and refuses for the same
|
|
463
|
+
* reason: an outcome that does not verify against the device's pinned key is
|
|
464
|
+
* an assertion by whoever relayed it, and storing it would let an
|
|
465
|
+
* intermediary write answers into the app.
|
|
466
|
+
*
|
|
467
|
+
* The clear-text `disposition` is checked here rather than trusted. It is on
|
|
468
|
+
* the wire so a relay can route without opening anything, which means the
|
|
469
|
+
* one thing it must not be is authoritative — a daemon that sealed an error
|
|
470
|
+
* and declared `ok` would otherwise have its declaration believed by
|
|
471
|
+
* everything upstream of this line.
|
|
472
|
+
*/
|
|
473
|
+
async #openResult(request, runner) {
|
|
474
|
+
const refuse = (why) => ({ ok: false, failure: fail("bad-request", why) });
|
|
475
|
+
const opened = await open2({
|
|
476
|
+
envelope: request.envelope,
|
|
477
|
+
recipientKeys: this.#siteKeys,
|
|
478
|
+
senderIdentityPublic: runner.device.identity,
|
|
479
|
+
expected: {
|
|
480
|
+
jobId: request.jobId,
|
|
481
|
+
senderKeyId: keyId2(runner.device.identity),
|
|
482
|
+
recipientKeyId: keyId2(publicIdentityOf2(this.#siteKeys).identity),
|
|
483
|
+
direction: "result"
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
if (!opened.ok) {
|
|
487
|
+
return refuse("the result did not verify as coming from this device");
|
|
488
|
+
}
|
|
489
|
+
let parsed;
|
|
490
|
+
try {
|
|
491
|
+
parsed = JSON.parse(opened.plaintext);
|
|
492
|
+
} catch {
|
|
493
|
+
return refuse("the sealed result was not valid JSON");
|
|
494
|
+
}
|
|
495
|
+
const sealed = SealedOutcome.safeParse(parsed);
|
|
496
|
+
if (!sealed.success) return refuse("the sealed result was not an outcome");
|
|
497
|
+
if (sealed.data.outcome.outcome !== request.disposition) {
|
|
498
|
+
return refuse("the declared disposition is not the one that was sealed");
|
|
499
|
+
}
|
|
500
|
+
return { ok: true, value: sealed.data };
|
|
501
|
+
}
|
|
502
|
+
// -- 5. release -----------------------------------------------------------
|
|
503
|
+
async #release(request, runner) {
|
|
504
|
+
if (request.runnerId !== runner.id) {
|
|
505
|
+
return fail("unauthorized", "runner id does not match the signing key");
|
|
506
|
+
}
|
|
507
|
+
const released = await this.#store.release({
|
|
508
|
+
runnerId: runner.id,
|
|
509
|
+
leases: request.leases,
|
|
510
|
+
reason: request.reason,
|
|
511
|
+
now: this.#now()
|
|
512
|
+
});
|
|
513
|
+
const response = { released };
|
|
514
|
+
return ok(response);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
var SERVED_PROTOCOL_VERSION = PROTOCOL_VERSION;
|
|
518
|
+
function leaseOf(job) {
|
|
519
|
+
if (!job.lease) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`store returned job ${job.id} from claim with no lease \u2014 the store contract requires a claimed job to hold one`
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
return job.lease;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/http.ts
|
|
528
|
+
import {
|
|
529
|
+
ENDPOINTS,
|
|
530
|
+
ERROR_STATUS as ERROR_STATUS2,
|
|
531
|
+
PROTOCOL_PREFIX,
|
|
532
|
+
checkProtocolVersion
|
|
533
|
+
} from "@byollm/protocol";
|
|
534
|
+
var MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
535
|
+
function normalizeBasePath(basePath) {
|
|
536
|
+
const trimmed = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
|
537
|
+
if (!trimmed.startsWith("/")) {
|
|
538
|
+
throw new Error(`basePath must start with "/": got ${basePath}`);
|
|
539
|
+
}
|
|
540
|
+
if (trimmed.includes("//") || /[?#*]/.test(trimmed)) {
|
|
541
|
+
throw new Error(`basePath must be a plain path: got ${basePath}`);
|
|
542
|
+
}
|
|
543
|
+
return trimmed;
|
|
544
|
+
}
|
|
545
|
+
function routeEndpoint(pathname, basePath = PROTOCOL_PREFIX) {
|
|
546
|
+
const base = normalizeBasePath(basePath);
|
|
547
|
+
const path = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
|
548
|
+
if (!path.startsWith(`${base}/`)) return null;
|
|
549
|
+
const rest = path.slice(base.length + 1);
|
|
550
|
+
return ENDPOINTS.includes(rest) ? rest : null;
|
|
551
|
+
}
|
|
552
|
+
function signatureFrom(headers) {
|
|
553
|
+
const runnerId = headers.get("x-byollm-runner");
|
|
554
|
+
const rawIssuedAt = headers.get("x-byollm-issued-at");
|
|
555
|
+
const signature = headers.get("x-byollm-signature");
|
|
556
|
+
if (runnerId === null || signature === null || rawIssuedAt === null) {
|
|
557
|
+
return void 0;
|
|
558
|
+
}
|
|
559
|
+
const issuedAt = Number(rawIssuedAt);
|
|
560
|
+
if (!Number.isFinite(issuedAt)) return void 0;
|
|
561
|
+
return { runnerId, issuedAt, signature };
|
|
562
|
+
}
|
|
563
|
+
function createFetchHandler(config) {
|
|
564
|
+
const handlers = new ByollmHandlers(config);
|
|
565
|
+
const basePath = normalizeBasePath(config.basePath ?? PROTOCOL_PREFIX);
|
|
566
|
+
return async function handle(request) {
|
|
567
|
+
if (request.method !== "POST") {
|
|
568
|
+
return json(405, {
|
|
569
|
+
error: "bad-request",
|
|
570
|
+
message: "protocol endpoints accept POST only"
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
const endpoint = routeEndpoint(new URL(request.url).pathname, basePath);
|
|
574
|
+
if (endpoint === null) {
|
|
575
|
+
return json(404, {
|
|
576
|
+
error: "not-found",
|
|
577
|
+
message: `not a ${basePath} endpoint`
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
const declared = request.headers.get("content-length");
|
|
581
|
+
if (declared !== null && Number(declared) > MAX_BODY_BYTES) {
|
|
582
|
+
return json(400, {
|
|
583
|
+
error: "bad-request",
|
|
584
|
+
message: "request body too large"
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
let body;
|
|
588
|
+
let rawBody;
|
|
589
|
+
try {
|
|
590
|
+
rawBody = await request.text();
|
|
591
|
+
const text = rawBody;
|
|
592
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
593
|
+
return json(400, {
|
|
594
|
+
error: "bad-request",
|
|
595
|
+
message: "request body too large"
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
body = JSON.parse(text);
|
|
599
|
+
} catch {
|
|
600
|
+
return json(400, {
|
|
601
|
+
error: "bad-request",
|
|
602
|
+
message: "request body is not valid JSON"
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
const refusal = checkProtocolVersion(body);
|
|
606
|
+
if (refusal) {
|
|
607
|
+
return json(ERROR_STATUS2[refusal.error], refusal);
|
|
608
|
+
}
|
|
609
|
+
const result = await handlers.handle(endpoint, body, {
|
|
610
|
+
endpoint,
|
|
611
|
+
// The bytes as received. Re-serialising the parsed object would verify
|
|
612
|
+
// a signature over something the sender never sent.
|
|
613
|
+
rawBody,
|
|
614
|
+
signature: signatureFrom(request.headers)
|
|
615
|
+
});
|
|
616
|
+
const headers = {
|
|
617
|
+
"content-type": "application/json",
|
|
618
|
+
"cache-control": "no-store"
|
|
619
|
+
};
|
|
620
|
+
if (result.retryAfterSeconds !== void 0) {
|
|
621
|
+
headers["retry-after"] = String(result.retryAfterSeconds);
|
|
622
|
+
}
|
|
623
|
+
return new Response(JSON.stringify(result.body), {
|
|
624
|
+
status: result.status,
|
|
625
|
+
headers
|
|
626
|
+
});
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
function json(status, body) {
|
|
630
|
+
return new Response(JSON.stringify(body), {
|
|
631
|
+
status,
|
|
632
|
+
headers: {
|
|
633
|
+
"content-type": "application/json",
|
|
634
|
+
"cache-control": "no-store"
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export {
|
|
640
|
+
generateDeviceCode,
|
|
641
|
+
generateRunnerId,
|
|
642
|
+
generateJobId,
|
|
643
|
+
generateUserCode,
|
|
644
|
+
hashSecret,
|
|
645
|
+
secretsMatch,
|
|
646
|
+
generateLeaseId,
|
|
647
|
+
deadlineFor,
|
|
648
|
+
resealForDevice,
|
|
649
|
+
ByollmHandlers,
|
|
650
|
+
SERVED_PROTOCOL_VERSION,
|
|
651
|
+
routeEndpoint,
|
|
652
|
+
signatureFrom,
|
|
653
|
+
createFetchHandler
|
|
654
|
+
};
|
|
655
|
+
//# sourceMappingURL=chunk-K5E6JS5A.js.map
|