@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.
@@ -0,0 +1,87 @@
1
+ -- The bearer token nobody used — cloud_008 §2.4, finding 37.
2
+ --
3
+ -- `byollm_runners.token_hash` held the SHA-256 of a token minted at pairing,
4
+ -- returned to the daemon, written to its pairings file, and then **never
5
+ -- sent, never looked up and never compared**. The only reader was
6
+ -- `getRunnerByTokenHash`, which both store adapters implemented and nothing
7
+ -- called except a test asserting it returns null.
8
+ --
9
+ -- That is not dead wire in the ordinary sense. It was a *secret*: minted,
10
+ -- transmitted once, and written to two disks at rest for no purpose. A
11
+ -- credential with no consumer cannot be used correctly and can still leak,
12
+ -- which makes it strictly a liability.
13
+ --
14
+ -- `REQUESTS_SIGNED_NOT_BEARER` was the rule the whole time and was enforced
15
+ -- the whole time: every authenticated call is signed by the device's pinned
16
+ -- identity key, and `C016` proves an endpoint refuses a bearer token. This
17
+ -- drops the thing the MUST is named after.
18
+ --
19
+ -- `byollm_pairings.runner_token_once` stays and now carries a marker rather
20
+ -- than a secret. It is the deliver-once flag — a replayed device code must
21
+ -- get nothing — a real property that was riding on the token's nullability.
22
+ -- Renaming a column and changing the code that reads it in one step is how a
23
+ -- rollback strands rows, so the rename waits for a later release.
24
+
25
+ alter table byollm_runners drop column if exists token_hash;
26
+
27
+ -- The browser-facing approval path takes one fewer argument. Replaced with
28
+ -- the old signature dropped rather than left beside it: two functions with
29
+ -- one name is how a caller ends up invoking the one nobody maintains.
30
+ --
31
+ -- Everything else is unchanged from the original, deliberately — the owner
32
+ -- still comes from `auth.uid()` because a daemon can never assert who it is,
33
+ -- which is the whole reason pairing is interactive.
34
+ drop function if exists byollm_approve_pairing(text, text);
35
+
36
+ create or replace function byollm_approve_pairing(
37
+ p_user_code text
38
+ )
39
+ returns byollm_runners
40
+ language plpgsql
41
+ security definer
42
+ set search_path = public
43
+ as $$
44
+ declare
45
+ v_pairing byollm_pairings;
46
+ v_runner byollm_runners;
47
+ v_owner uuid := (select auth.uid());
48
+ begin
49
+ if v_owner is null then
50
+ raise exception 'approving a pairing requires an authenticated user';
51
+ end if;
52
+
53
+ select * into v_pairing from byollm_pairings
54
+ where user_code = p_user_code for update;
55
+
56
+ if v_pairing is null then
57
+ raise exception 'unknown pairing code';
58
+ end if;
59
+ if v_pairing.expires_at <= now() then
60
+ raise exception 'pairing code has expired';
61
+ end if;
62
+ if v_pairing.state <> 'pending' then
63
+ raise exception 'pairing is already %', v_pairing.state;
64
+ end if;
65
+
66
+ insert into byollm_runners (owner, label, platform,
67
+ daemon_version, capabilities, device)
68
+ values (v_owner, v_pairing.label, v_pairing.platform,
69
+ v_pairing.daemon_version, v_pairing.capabilities, v_pairing.device)
70
+ returning * into v_runner;
71
+
72
+ -- The marker, not a token: this is what makes a replayed device code get
73
+ -- nothing. The service-role path in `supabase/index.ts` writes the same
74
+ -- value, and the two approval doors have to agree — a field set by one and
75
+ -- not the other produces a runner that is correct through one and broken
76
+ -- through the other.
77
+ update byollm_pairings
78
+ set state = 'approved',
79
+ owner = v_owner,
80
+ runner_id = v_runner.id,
81
+ runner_token_once = 'pending-collection'
82
+ where device_code_hash = v_pairing.device_code_hash;
83
+
84
+ return v_runner;
85
+ end;
86
+ $$;
87
+
@@ -0,0 +1,25 @@
1
+ -- Which grant recorded a result — cloud_008 §3.6.
2
+ --
3
+ -- `complete` now checks terminal state **before** the holder, so
4
+ -- `RESULT_IDEMPOTENT` is enforced by the branch named after it rather than as
5
+ -- a side effect of the lease being nulled on success. Answering a replay
6
+ -- correctly means knowing which grant recorded the result, and `lease_id` is
7
+ -- cleared at completion by design: "who holds this" and "who finished this"
8
+ -- are different questions with different lifetimes.
9
+ --
10
+ -- ## Why this is its own file
11
+ --
12
+ -- It was first appended to `20260819000000_drop_runner_token.sql`, which had
13
+ -- already shipped in alpha.19. An applied migration is immutable — that is the
14
+ -- whole reason a migrations folder can be trusted — and the fact that this one
15
+ -- is only days old and probably applied nowhere does not make editing it a
16
+ -- different act. The rule is worth more than the tidiness.
17
+ --
18
+ -- ## Nullable, and not backfilled
19
+ --
20
+ -- Rows completed before this migration have no recorded grant, so a replay of
21
+ -- one of them is answered as a plain refusal rather than as a duplicate. That
22
+ -- is the safe direction: it withholds a reassurance rather than inventing one,
23
+ -- and the daemons that produced those rows stopped retrying long ago.
24
+ alter table byollm_jobs
25
+ add column if not exists completed_by_lease_id text;
@@ -0,0 +1,91 @@
1
+ -- The deliver-once flag stops pretending to be a token — cloud_008 §2.4a.
2
+ --
3
+ -- `20260819000000_drop_runner_token` removed the bearer credential and left
4
+ -- `byollm_pairings.runner_token_once` carrying a marker string, with the
5
+ -- reason written down: *"Renaming a column and changing the code that reads
6
+ -- it in one step is how a rollback strands rows, so the rename waits for a
7
+ -- later release."*
8
+ --
9
+ -- This is that release. The rename lands with the code that reads it, in one
10
+ -- step, because the transitional shape exists to protect a party who has not
11
+ -- agreed to change — and pre-1.0, with one deployment and one operator, that
12
+ -- party is us. Carrying a column named after a secret it no longer holds is
13
+ -- a comment that has to be re-read by everybody who meets it.
14
+ --
15
+ -- The property is unchanged and is the reason the column survives at all: a
16
+ -- replayed device code must get nothing, or a code left in a shell history is
17
+ -- a second pairing. That was riding on a token's nullability, which was one
18
+ -- field doing two jobs — and only one of them load-bearing.
19
+
20
+ alter table byollm_pairings
21
+ rename column runner_token_once to collected_at;
22
+
23
+ -- A timestamp rather than a marker string. `'pending-collection'` was the
24
+ -- shape a nulled token left behind; what the flag actually records is *when*
25
+ -- the approval was handed over, which is worth having when somebody asks why
26
+ -- a pairing did not complete.
27
+ alter table byollm_pairings
28
+ alter column collected_at type timestamptz
29
+ using case when collected_at is null then now() else null end;
30
+
31
+ comment on column byollm_pairings.collected_at is
32
+ 'When the approval was collected. Null until then — a replayed device code '
33
+ 'gets nothing. cloud_008 §2.4a; this was runner_token_once.';
34
+
35
+ -- The approval function moves with the column it writes. Recreated whole
36
+ -- rather than patched, because two functions with one name is how a caller
37
+ -- ends up invoking the one nobody maintains — the argument the migration
38
+ -- before this one made when it replaced the old signature.
39
+
40
+ create or replace function byollm_approve_pairing(
41
+ p_user_code text
42
+ )
43
+ returns byollm_runners
44
+ language plpgsql
45
+ security definer
46
+ set search_path = public
47
+ as $$
48
+ declare
49
+ v_pairing byollm_pairings;
50
+ v_runner byollm_runners;
51
+ v_owner uuid := (select auth.uid());
52
+ begin
53
+ if v_owner is null then
54
+ raise exception 'approving a pairing requires an authenticated user';
55
+ end if;
56
+
57
+ select * into v_pairing from byollm_pairings
58
+ where user_code = p_user_code for update;
59
+
60
+ if v_pairing is null then
61
+ raise exception 'unknown pairing code';
62
+ end if;
63
+ if v_pairing.expires_at <= now() then
64
+ raise exception 'pairing code has expired';
65
+ end if;
66
+ if v_pairing.state <> 'pending' then
67
+ raise exception 'pairing is already %', v_pairing.state;
68
+ end if;
69
+
70
+ insert into byollm_runners (owner, label, platform,
71
+ daemon_version, capabilities, device)
72
+ values (v_owner, v_pairing.label, v_pairing.platform,
73
+ v_pairing.daemon_version, v_pairing.capabilities, v_pairing.device)
74
+ returning * into v_runner;
75
+
76
+ -- The marker, not a token: this is what makes a replayed device code get
77
+ -- nothing. The service-role path in `supabase/index.ts` writes the same
78
+ -- value, and the two approval doors have to agree — a field set by one and
79
+ -- not the other produces a runner that is correct through one and broken
80
+ -- through the other.
81
+ update byollm_pairings
82
+ set state = 'approved',
83
+ owner = v_owner,
84
+ runner_id = v_runner.id,
85
+ collected_at = null
86
+ where device_code_hash = v_pairing.device_code_hash;
87
+
88
+ return v_runner;
89
+ end;
90
+ $$;
91
+
@@ -1,422 +0,0 @@
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 generateRunnerToken() {
13
- return randomBytes(32).toString("base64url");
14
- }
15
- function generateRunnerId() {
16
- return `runner_${randomUUID()}`;
17
- }
18
- function generateJobId() {
19
- return `job_${randomUUID()}`;
20
- }
21
- function generateUserCode() {
22
- const chars = [];
23
- while (chars.length < 8) {
24
- for (const byte of randomBytes(16)) {
25
- const limit = 256 - 256 % USER_CODE_ALPHABET.length;
26
- if (byte >= limit) continue;
27
- const symbol = USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length];
28
- if (symbol === void 0) continue;
29
- chars.push(symbol);
30
- if (chars.length === 8) break;
31
- }
32
- }
33
- return `${chars.slice(0, 4).join("")}-${chars.slice(4).join("")}`;
34
- }
35
- function hashSecret(secret) {
36
- return createHash("sha256").update(secret, "utf8").digest("hex");
37
- }
38
- function secretsMatch(aHex, bHex) {
39
- if (aHex.length !== bHex.length) return false;
40
- return timingSafeEqual(Buffer.from(aHex, "hex"), Buffer.from(bHex, "hex"));
41
- }
42
-
43
- // src/handlers.ts
44
- import {
45
- ClaimRequest,
46
- ERROR_STATUS,
47
- HeartbeatRequest,
48
- PairRequest,
49
- PROTOCOL_VERSION,
50
- ReleaseRequest,
51
- ResultRequest,
52
- provenanceFor
53
- } from "@byollm/protocol";
54
- var DEFAULTS = {
55
- leaseMs: 6e4,
56
- pairingTtlMs: 10 * 6e4,
57
- pollIntervalMs: 2e3
58
- };
59
- function fail(error, message, retryAfterSeconds) {
60
- return {
61
- status: ERROR_STATUS[error],
62
- body: {
63
- error,
64
- message,
65
- ...retryAfterSeconds === void 0 ? {} : { retryAfter: retryAfterSeconds }
66
- },
67
- ...retryAfterSeconds === void 0 ? {} : { retryAfterSeconds }
68
- };
69
- }
70
- function ok(body) {
71
- return { status: 200, body };
72
- }
73
- var ByollmHandlers = class {
74
- #store;
75
- #verificationUrl;
76
- #leaseMs;
77
- #pairingTtlMs;
78
- #pollIntervalMs;
79
- #now;
80
- constructor(config) {
81
- this.#store = config.store;
82
- this.#verificationUrl = config.verificationUrl;
83
- this.#leaseMs = config.leaseMs ?? DEFAULTS.leaseMs;
84
- this.#pairingTtlMs = config.pairingTtlMs ?? DEFAULTS.pairingTtlMs;
85
- this.#pollIntervalMs = config.pollIntervalMs ?? DEFAULTS.pollIntervalMs;
86
- this.#now = config.now ?? Date.now;
87
- }
88
- /**
89
- * Dispatch one protocol call.
90
- *
91
- * @param endpoint - which of the five, already routed from the path
92
- * @param body - the parsed JSON request body, untrusted
93
- * @param bearer - the `Authorization: Bearer` value, if any
94
- */
95
- async handle(endpoint, body, bearer) {
96
- switch (endpoint) {
97
- case "pair":
98
- return this.#pair(body);
99
- case "claim":
100
- return this.#authed(bearer, body, ClaimRequest, this.#claim.bind(this));
101
- case "heartbeat":
102
- return this.#authed(
103
- bearer,
104
- body,
105
- HeartbeatRequest,
106
- this.#heartbeat.bind(this),
107
- { allowRevoked: true }
108
- );
109
- case "result":
110
- return this.#authed(
111
- bearer,
112
- body,
113
- ResultRequest,
114
- this.#result.bind(this)
115
- );
116
- case "release":
117
- return this.#authed(
118
- bearer,
119
- body,
120
- ReleaseRequest,
121
- this.#release.bind(this)
122
- );
123
- }
124
- }
125
- /**
126
- * Shared preamble for the four authenticated endpoints: resolve the bearer
127
- * token to a runner, reject a revoked one, and parse the body.
128
- *
129
- * The token→runner lookup happens before schema validation so a stranger
130
- * probing the endpoint learns nothing about the wire format.
131
- */
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");
135
- }
136
- const runner = await this.#store.getRunnerByTokenHash(hashSecret(bearer));
137
- if (!runner) {
138
- return fail("unauthorized", "this runner token is not recognised");
139
- }
140
- if (runner.revokedAt !== null && options.allowRevoked !== true) {
141
- return fail("revoked", "this runner has been revoked by its owner");
142
- }
143
- const parsed = schema.safeParse(body);
144
- if (!parsed.success || parsed.data === void 0) {
145
- return fail("bad-request", "request body failed schema validation");
146
- }
147
- return run(parsed.data, runner);
148
- }
149
- // -- 1. pair --------------------------------------------------------------
150
- async #pair(body) {
151
- const parsed = PairRequest.safeParse(body);
152
- if (!parsed.success) {
153
- return fail("bad-request", "pair request failed schema validation");
154
- }
155
- const request = parsed.data;
156
- const now = this.#now();
157
- if (request.action === "start") {
158
- const deviceCode = generateDeviceCode();
159
- const userCode = generateUserCode();
160
- const expiresAt = now + this.#pairingTtlMs;
161
- await this.#store.createPairing({
162
- deviceCodeHash: hashSecret(deviceCode),
163
- userCode,
164
- state: "pending",
165
- owner: null,
166
- runnerId: null,
167
- runnerTokenOnce: null,
168
- label: request.daemon.label,
169
- platform: request.daemon.platform,
170
- daemonVersion: request.daemon.version,
171
- capabilities: request.capabilities,
172
- expiresAt,
173
- createdAt: now
174
- });
175
- const response = {
176
- deviceCode,
177
- userCode,
178
- verificationUrl: this.#verificationUrl,
179
- expiresAt,
180
- pollIntervalMs: this.#pollIntervalMs
181
- };
182
- return ok(response);
183
- }
184
- const pairing = await this.#store.getPairingByDeviceCodeHash(
185
- hashSecret(request.deviceCode)
186
- );
187
- if (!pairing) {
188
- return fail("not-found", "unknown device code");
189
- }
190
- if (pairing.state === "denied") {
191
- return ok({ status: "denied" });
192
- }
193
- if (pairing.expiresAt <= now && pairing.state === "pending") {
194
- return ok({ status: "expired" });
195
- }
196
- if (pairing.state === "approved" && pairing.runnerTokenOnce !== null && pairing.runnerId !== null && pairing.owner !== null) {
197
- const response = {
198
- status: "approved",
199
- runnerToken: pairing.runnerTokenOnce,
200
- runnerId: pairing.runnerId,
201
- owner: pairing.owner
202
- };
203
- await this.#store.consumePairingToken(pairing.deviceCodeHash);
204
- return ok(response);
205
- }
206
- if (pairing.state === "approved") {
207
- return fail("not-found", "this pairing has already been collected");
208
- }
209
- return ok({ status: "pending" });
210
- }
211
- // -- 2. claim -------------------------------------------------------------
212
- async #claim(request, runner) {
213
- if (request.runnerId !== runner.id) {
214
- return fail("unauthorized", "runner id does not match the bearer token");
215
- }
216
- const now = this.#now();
217
- const jobs = await this.#store.claim({
218
- runnerId: runner.id,
219
- runnerOwner: runner.owner,
220
- capabilities: request.capabilities,
221
- max: request.max,
222
- leaseMs: this.#leaseMs,
223
- now
224
- });
225
- const response = {
226
- jobs: jobs.map((job) => ({
227
- id: job.id,
228
- kind: job.kind,
229
- payload: job.payload,
230
- audience: job.audience,
231
- owner: job.owner,
232
- ...job.audienceAllow === void 0 ? {} : { audienceAllow: [...job.audienceAllow] },
233
- lease: job.lease ?? {
234
- runnerId: runner.id,
235
- expiresAt: now + this.#leaseMs
236
- }
237
- })),
238
- leaseMs: this.#leaseMs
239
- };
240
- return ok(response);
241
- }
242
- // -- 3. heartbeat ---------------------------------------------------------
243
- async #heartbeat(request, runner) {
244
- if (request.runnerId !== runner.id) {
245
- return fail("unauthorized", "runner id does not match the bearer token");
246
- }
247
- const now = this.#now();
248
- const revoked = runner.revokedAt !== null;
249
- if (revoked) {
250
- const held = await this.#store.listClaimedBy(runner.id);
251
- const response2 = {
252
- revoked: true,
253
- cancel: [],
254
- leases: [],
255
- lost: held.map((job) => job.id),
256
- serverTime: now
257
- };
258
- return ok(response2);
259
- }
260
- await this.#store.touchRunner({
261
- runnerId: runner.id,
262
- capabilities: request.capabilities,
263
- daemonVersion: request.daemonVersion,
264
- paused: request.paused,
265
- now
266
- });
267
- const { renewed, lost } = await this.#store.renewLeases({
268
- runnerId: runner.id,
269
- jobIds: request.activeJobIds,
270
- leaseMs: this.#leaseMs,
271
- now
272
- });
273
- const cancel = await this.#store.listCancelRequests(runner.id);
274
- const response = {
275
- revoked: false,
276
- cancel,
277
- leases: renewed.map((r) => ({ jobId: r.jobId, expiresAt: r.expiresAt })),
278
- lost: [...lost],
279
- serverTime: now
280
- };
281
- return ok(response);
282
- }
283
- // -- 4. result ------------------------------------------------------------
284
- async #result(request, runner) {
285
- if (request.runnerId !== runner.id) {
286
- return fail("unauthorized", "runner id does not match the bearer token");
287
- }
288
- const now = this.#now();
289
- const job = await this.#store.get(request.jobId);
290
- if (!job) return fail("not-found", "unknown job");
291
- const provenance = provenanceFor({
292
- audience: job.audience,
293
- runnerId: runner.id,
294
- runnerOwner: runner.owner,
295
- backendClass: request.backendClass,
296
- model: request.model
297
- });
298
- const { accepted, job: updated } = await this.#store.complete({
299
- jobId: request.jobId,
300
- runnerId: runner.id,
301
- outcome: request.outcome,
302
- provenance,
303
- now
304
- });
305
- const response = {
306
- accepted,
307
- state: updated?.state ?? job.state
308
- };
309
- return ok(response);
310
- }
311
- // -- 5. release -----------------------------------------------------------
312
- async #release(request, runner) {
313
- if (request.runnerId !== runner.id) {
314
- return fail("unauthorized", "runner id does not match the bearer token");
315
- }
316
- const released = await this.#store.release({
317
- runnerId: runner.id,
318
- jobIds: request.jobIds,
319
- reason: request.reason,
320
- now: this.#now()
321
- });
322
- const response = { released };
323
- return ok(response);
324
- }
325
- };
326
- var SERVED_PROTOCOL_VERSION = PROTOCOL_VERSION;
327
-
328
- // src/http.ts
329
- import { ENDPOINTS, PROTOCOL_PREFIX } from "@byollm/protocol";
330
- 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;
335
- }
336
- function bearerFrom(header) {
337
- if (!header) return void 0;
338
- const match = /^Bearer[ ]+(.+)$/i.exec(header.trim());
339
- return match?.[1];
340
- }
341
- function createFetchHandler(config) {
342
- const handlers = new ByollmHandlers(config);
343
- return async function handle(request) {
344
- if (request.method !== "POST") {
345
- return json(405, {
346
- error: "bad-request",
347
- message: "protocol endpoints accept POST only"
348
- });
349
- }
350
- const endpoint = routeEndpoint(new URL(request.url).pathname);
351
- if (endpoint === null) {
352
- return json(404, {
353
- error: "not-found",
354
- message: `not a ${PROTOCOL_PREFIX} endpoint`
355
- });
356
- }
357
- const declared = request.headers.get("content-length");
358
- if (declared !== null && Number(declared) > MAX_BODY_BYTES) {
359
- return json(400, {
360
- error: "bad-request",
361
- message: "request body too large"
362
- });
363
- }
364
- let body;
365
- try {
366
- const text = await request.text();
367
- if (text.length > MAX_BODY_BYTES) {
368
- return json(400, {
369
- error: "bad-request",
370
- message: "request body too large"
371
- });
372
- }
373
- body = JSON.parse(text);
374
- } catch {
375
- return json(400, {
376
- error: "bad-request",
377
- message: "request body is not valid JSON"
378
- });
379
- }
380
- const result = await handlers.handle(
381
- endpoint,
382
- body,
383
- bearerFrom(request.headers.get("authorization"))
384
- );
385
- const headers = {
386
- "content-type": "application/json",
387
- "cache-control": "no-store"
388
- };
389
- if (result.retryAfterSeconds !== void 0) {
390
- headers["retry-after"] = String(result.retryAfterSeconds);
391
- }
392
- return new Response(JSON.stringify(result.body), {
393
- status: result.status,
394
- headers
395
- });
396
- };
397
- }
398
- function json(status, body) {
399
- return new Response(JSON.stringify(body), {
400
- status,
401
- headers: {
402
- "content-type": "application/json",
403
- "cache-control": "no-store"
404
- }
405
- });
406
- }
407
-
408
- export {
409
- generateDeviceCode,
410
- generateRunnerToken,
411
- generateRunnerId,
412
- generateJobId,
413
- generateUserCode,
414
- hashSecret,
415
- secretsMatch,
416
- ByollmHandlers,
417
- SERVED_PROTOCOL_VERSION,
418
- routeEndpoint,
419
- bearerFrom,
420
- createFetchHandler
421
- };
422
- //# sourceMappingURL=chunk-HL6EYHQ7.js.map