@byollm/server 0.1.0-alpha.1 → 0.1.0-alpha.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  > [!WARNING]
2
- > **Alpha (`0.1.0-alpha.1`) — under active development. Don't use this yet.**
2
+ > **Alpha (`0.1.0-alpha.100`) — under active development. Don't use this yet.**
3
3
  >
4
4
  > Install it deliberately: `npm install @byollm/server@alpha`.
5
5
  >
@@ -11,7 +11,135 @@
11
11
  > npm assigns `latest` on a first publish and won't let it be removed, so a
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
- > Ask for `@alpha` explicitly so your lockfile records that you meant to.
14
+ > Ask for `@alpha` explicitly so your lockfile records that you meant to.>
15
+ > **`alpha.15` is a breaking wire change, and it breaks daemons and relays —
16
+ > not app authors.** If you call `app.enqueue(...)` and read results, nothing
17
+ > in your code changes. If you run a daemon or an upstream, every package must
18
+ > move together: a mixed pair refuses on both sides, because both ends parse
19
+ > `.strict()`.
20
+ >
21
+ > What moved, all of it reconciling the frozen `byollm_009` with its code:
22
+ > `JobStub` gains `site` (the site's identity key id) and loses
23
+ > `audienceAllow`; `ResultRequest` gains `leaseId`; `HeartbeatResponse` loses
24
+ > `leases`, which nothing read; `WireErrorCode` gains `not-ready`,
25
+ > `clock-skew` and `forbidden`, and `403` is `forbidden` rather than
26
+ > `unauthorized`. `RESULT_PROVENANCE` is superseded by
27
+ > `PROVENANCE_NAMES_DEVICE`. See `byollm_009` Amendment A.>
28
+ > **`alpha.16` is a breaking wire change — daemons and relays again, not app
29
+ > authors.** `app.enqueue(...)` and reading results are unchanged. All five
30
+ > packages move together: both ends parse `.strict()`, so a mixed pair
31
+ > refuses.
32
+ >
33
+ > What moved, all of it Tier 2 of `cloud_008`: `model`, `backendClass` and
34
+ > `durationMs` come off `ResultRequest` and are sealed **inside** the result
35
+ > envelope as `SealedOutcome = { outcome, ran }` — so a daemon can no longer
36
+ > declare a model it did not sign, and a relay carries neither.
37
+ > `HeartbeatResponse` loses `leases` (nothing read it) and now reports real
38
+ > cancellations instead of an empty list. `WireErrorCode` gains `forbidden`
39
+ > for 403, leaving `unauthorized` at exactly 401. The relay gained a
40
+ > site-plane `cancel` endpoint, honours `stub.deadlineAt`, honours
41
+ > `stub.audience`, and remembers a refusal.>
42
+ > **`alpha.17` is additive** — no wire change. It exports `ReleaseReason`,
43
+ > which `RoutingStore.releaseLeases` names and the package did not export, so
44
+ > the interface was unimplementable outside this repo.>
45
+ > **`alpha.18` is a breaking wire change — daemons and relays, not app
46
+ > authors.** `app.enqueue(...)` and reading results are unchanged. All five
47
+ > packages move together.
48
+ >
49
+ > The **bearer token is gone**: off `PairPollResponse`, off the runner row,
50
+ > off the daemon's pairings file, out of the adapter's schema. It was minted,
51
+ > hashed and stored on two disks and never sent, looked up or compared —
52
+ > `REQUESTS_SIGNED_NOT_BEARER` was enforced by signatures the whole time. If
53
+ > you run the Supabase adapter, apply
54
+ > `20260819000000_drop_runner_token.sql`; `byollm_approve_pairing` now takes
55
+ > one argument. A pairings file written by an older daemon still loads.
56
+ >
57
+ > `model`, `backendClass` and `durationMs` moved **inside** the sealed result
58
+ > (`SealedOutcome = { outcome, ran }`), so a daemon cannot declare a model it
59
+ > did not sign and a relay carries none of them. Writing a `RoutingStore`?
60
+ > `releaseLeases` takes an optional `reason` and `complete` requires
61
+ > `leaseId`, and **an implementation that ignores either still typechecks** —
62
+ > run the store contract tests.>
63
+ > **`alpha.19` is additive on the wire and a behaviour change in every
64
+ > store.** `ResultResponse` gains an optional `duplicate`. Nothing is removed,
65
+ > so an older daemon keeps working — but the *order* two rules are checked in
66
+ > has changed, and a `RoutingStore` implementation must change with it.
67
+ >
68
+ > `complete` now checks **terminal state before holder**, scoped to the device
69
+ > that finished the job: a replay from that device is answered `duplicate:
70
+ > true` with a 2xx, and anyone else gets exactly the refusal they would get
71
+ > for a job that is not terminal. Previously `RESULT_IDEMPOTENT` held only
72
+ > because the lease is nulled on success, so the holder check tripped first —
73
+ > deleting the idempotency branch failed no test. Run the store contract
74
+ > tests; the compiler cannot see this.
75
+ >
76
+ > **`alpha.7` breaks nobody who is not implementing a relay.** `@byollm/relay`'s
77
+ > projection gains `devices` — the device keys a control plane has approved —
78
+ > and the relay refuses to pair a device that is not in it. `revoked` becomes
79
+ > `{owner, siteId}` instead of a composite string. Sites, daemons, stores and
80
+ > the wire format are all untouched; no runner re-pairs.
81
+ >
82
+ > **`alpha.5` broke store adapters, and nothing else.** If you implement
83
+ > `JobStore` yourself, two changes are required: a new `adopt(args)` method
84
+ > (record a lease granted by an upstream this store does not own), and
85
+ > `CompleteArgs.runnerId` is replaced by `holder` — a discriminated union
86
+ > naming either a runner or a lease. Apps, daemons and the wire format are
87
+ > unaffected; `@byollm/conformance` will tell you if you missed one.
88
+ >
89
+ > **`alpha.4` broke every integration.** Three things changed for you:
90
+ >
91
+ > 1. **`siteKeys` is required.** Run `npx @byollm/server@alpha keygen` once,
92
+ > set `BYOLLM_SITE_KEYS`, and pass it to `ByollmApp` and `createHandler`.
93
+ > Once — not per deploy, never at startup.
94
+ > 2. **`createHandler` takes a function.** `next build` imports route modules
95
+ > with no secrets present, so a config object fails the build.
96
+ > 3. **Every paired runner re-pairs.** Bearer tokens are replaced by per-request
97
+ > signatures against a pinned device key, so old tokens authenticate nothing.
98
+ >
99
+ > Your store adapter is unaffected: payloads and results are sealed before they
100
+ > reach it, and `JobStore` did not change.
101
+
102
+ <!-- release-note 0.1.0-alpha.21 -->
103
+ > [!NOTE]
104
+ > **`0.1.0-alpha.20` is not a complete release — do not pin it.** Four
105
+ > packages published and `@byollm/server` did not: a Sigstore
106
+ > transparency-log 409 on its provenance attestation. The workflow's
107
+ > "already published" guard correctly refuses to resume a partial publish,
108
+ > so `0.1.0-alpha.21` is that release, whole.
109
+ >
110
+ > If you run the Supabase adapter, `alpha.21` needs
111
+ > `20260819010000_completed_by_lease_id.sql`: alpha.19 shipped §3.6's
112
+ > ordering without the column it stores the grant in.
113
+
114
+ <!-- release-note 0.1.0-alpha.40 -->
115
+ **`byollm start` — stop keeping a terminal open.** The daemon can now run
116
+ under your computer's own supervisor and restart itself if it stops: a launchd
117
+ agent on macOS, a `systemd --user` unit on Linux, a logon task on Windows. All
118
+ user-level — no root, no system directories, and `byollm stop` takes it
119
+ away. `byollm status` gained a line saying whether it is actually supervised
120
+ right now, including the state that matters most: installed but not running,
121
+ which looks fine from an app's dashboard and serves nothing.
122
+
123
+ If you are running via `npx`, install properly first (`npm install -g
124
+ byollm@latest`) — `install` refuses to supervise a copy in npx's cache, because
125
+ npm deletes that directory and the service would fail at some later boot.
126
+
127
+ <!-- release-note 0.1.0-alpha.41 -->
128
+ **`onNoRunner` takes a string.** Your fallback answer is your own value, not
129
+ wire data, and handing back a whole result record for it was ceremony — the
130
+ README's own example got the shape wrong, which is how this was found.
131
+
132
+ ```ts
133
+ const { outcome, fallback } = await job.result({
134
+ onNoRunner: () => runOnHostedModel(transcript),
135
+ });
136
+ ```
137
+
138
+ Whatever you return, `result()` labels it `fallback: true` — the stamp is
139
+ applied by the wait, not taken from you, so an answer that did not run on
140
+ somebody's device cannot be reported as though it did (`FALLBACK_LABELED`).
141
+ Both delivery channels do it, polling and Supabase Realtime. Records still
142
+ work; they just get labelled too.
15
143
 
16
144
  # `@byollm/server`
17
145
 
@@ -30,44 +158,139 @@ npm install @byollm/server
30
158
  ```ts
31
159
  // app/api/byollm/[...route]/route.ts
32
160
  import { createHandler } from "@byollm/server/next";
33
- import { store } from "@/lib/byollm";
161
+ import { siteKeysFromEnv } from "@byollm/server";
162
+ import { getStore } from "@/lib/byollm";
34
163
 
35
- export const { POST } = createHandler({
36
- store,
164
+ export const { POST } = createHandler(() => ({
165
+ store: getStore(),
166
+ siteKeys: siteKeysFromEnv("BYOLLM_SITE_KEYS"),
37
167
  verificationUrl: "https://your-app.com/settings/runners",
38
- });
168
+ // Next serves this route under /api, so say where it is mounted. The
169
+ // handler matches the full path and will 404 without this.
170
+ basePath: "/api/byollm",
171
+ }));
39
172
  ```
40
173
 
174
+ **Pass a function, not an object.** `next build` imports every route module to
175
+ collect page data, in an environment that has no secrets. A config object is
176
+ constructed during that import, so the build fails on credentials it cannot
177
+ have. A function is not called until the first request.
178
+
179
+ Then pair against that same path — `byollm connect https://your-app.com/api`.
180
+ The daemon appends `/byollm/<endpoint>` to whatever origin it is given, so
181
+ connecting to the bare domain looks for `/byollm/claim` and finds nothing.
182
+ To serve at `/byollm` instead, put the route at `app/byollm/[...route]/route.ts`,
183
+ drop `basePath`, and pair against the bare domain.
184
+
41
185
  **2. Pick a store.**
42
186
 
43
187
  ```ts
44
188
  // lib/byollm.ts
45
- import { ByollmApp, MemoryStore } from "@byollm/server";
189
+ import { ByollmApp, MemoryStore, siteKeysFromEnv } from "@byollm/server";
190
+
191
+ // Lazily, and memoized, for the same reason the mount takes a function: a
192
+ // module-scope `new` runs during `next build`.
193
+ let store: MemoryStore | undefined;
194
+ export function getStore(): MemoryStore {
195
+ return (store ??= new MemoryStore());
196
+ }
197
+
198
+ let app: ByollmApp | undefined;
199
+ export function getApp(): ByollmApp {
200
+ return (app ??= new ByollmApp({
201
+ store: getStore(),
202
+ siteKeys: siteKeysFromEnv("BYOLLM_SITE_KEYS"),
203
+ }));
204
+ }
205
+ ```
206
+
207
+ Generate that identity once, and keep it:
46
208
 
47
- export const store = new MemoryStore();
48
- export const app = new ByollmApp({ store });
209
+ ```bash
210
+ npx @byollm/server@alpha keygen # prints BYOLLM_SITE_KEYS=...
49
211
  ```
50
212
 
213
+ Once, not per deploy and never at startup — a daemon pins this identity when
214
+ its owner approves the pairing, and regenerating it means every paired device
215
+ must pair again. Generating at startup fails only under horizontal scale: each
216
+ instance would have a different identity, and a daemon would be refused by
217
+ whichever one it did not pair with.
218
+
51
219
  **3. Enqueue.**
52
220
 
53
221
  ```ts
54
- const job = await app.enqueue({
222
+ const job = await getApp().enqueue({
55
223
  kind: "llm.generate",
56
- audience: "self", // this user's own machine only — the default
57
- owner: userId,
224
+ audience: "private", // this user's own device only — the default
225
+ owner: ownerId, // who this is depends on your mode — see below
58
226
  payload: { prompt: `Summarize this transcript:\n\n${transcript}` },
59
227
  });
60
228
 
61
- const { outcome } = await job.result({
229
+ const { outcome, fallback } = await job.result({
62
230
  timeoutMs: 120_000,
231
+ // A string is enough — it is your own fallback answer, not wire data.
63
232
  onNoRunner: () => runOnHostedModel(transcript),
64
233
  });
234
+
235
+ // `fallback` is true when nobody's device ran it and this came from your
236
+ // own substitute. Say so wherever you show the answer: work that did not run
237
+ // on the user's compute must not be presented as though it did.
65
238
  ```
66
239
 
67
240
  `result()` is sugar over your delivery channel with a timeout and a
68
241
  `noRunnerAvailable` path — never a bare promise that hangs forever. If nobody
69
242
  is online to run the job, you find out and can fall back.
70
243
 
244
+ ## Who `owner` is — this differs by mode
245
+
246
+ `owner` names the person whose devices should do the work, and the two
247
+ connection modes do not use the same names for people. Getting this wrong is
248
+ the one integration mistake that produces no error: the job enqueues, returns
249
+ an id, and never routes.
250
+
251
+ **Direct** — daemons reach your own handlers, and you are the only party who
252
+ knows who anybody is. `owner` is your own user id, from your session, never
253
+ from the client. That is what the example above shows.
254
+
255
+ **Cloud** — daemons reach `hub.byollm.cloud`, which has its own identity
256
+ space: rosters, consents and budgets all speak **BYOLLM ids**. Your user id
257
+ means nothing there. `owner` must be the person's BYOLLM id.
258
+
259
+ Ask them for it. Every signed-in person can read it on their byollm.cloud
260
+ account page under **Your BYOLLM id**, with a copy button. Add a settings
261
+ field, have them paste it, store it against your own user record.
262
+
263
+ The id names them and authorises nothing — someone holding it can address work
264
+ to a person and cannot deliver it, because the consent row is what opens the
265
+ route. So a pasted id is not a credential you are being trusted with, and a
266
+ wrong one is harmless. Two things still matter: it must come from the person's
267
+ own account rather than being inferred by your app, and they must connect your
268
+ site on byollm.cloud before anything routes.
269
+
270
+ **A wrong id is silence, not an error.** The relay routes on a consented
271
+ `(site, owner)` pair, so a mistyped id — or one belonging to somebody who has
272
+ not connected your site — matches no route. The job waits, then expires. So
273
+ check ids when you receive them rather than when you enqueue:
274
+
275
+ ```ts
276
+ const { available } = await getApp().runnerAvailability({
277
+ kind: "llm.generate",
278
+ owner: pastedByollmId,
279
+ });
280
+
281
+ if (!available) {
282
+ // Existence-neutral, deliberately.
283
+ return "That id has no devices for you. Check it, or connect this site on byollm.cloud.";
284
+ }
285
+ ```
286
+
287
+ Keep that message vague on purpose. A typo'd id and an id belonging to somebody
288
+ who has not connected you give the same answer, and that is the system working
289
+ rather than a limitation to route around — telling them apart would make this
290
+ call an account-existence oracle: probe a guess, sort the answers, enumerate.
291
+ Never write "no such account". If you ever get an answer that *does*
292
+ distinguish them, that is a bug worth reporting.
293
+
71
294
  ## The approval page
72
295
 
73
296
  Pairing is a device-code exchange, so you need one page where a signed-in user
@@ -75,13 +298,13 @@ types the code their daemon showed them:
75
298
 
76
299
  ```ts
77
300
  // The owner comes from YOUR session. A daemon can never assert who it is.
78
- const runner = await app.approvePairing({
301
+ const runner = await getApp().approvePairing({
79
302
  userCode: formData.get("code"),
80
303
  owner: session.userId,
81
304
  });
82
305
  ```
83
306
 
84
- `app.pendingPairing(code)` tells you what they are about to approve — machine
307
+ `app.pendingPairing(code)` tells you what they are about to approve — device
85
308
  label, platform, and which models it is offering — so the page can show it.
86
309
 
87
310
  ## Stores
@@ -103,6 +326,7 @@ import {
103
326
  const store = supabaseStore({ client: serviceRoleClient });
104
327
  const app = new ByollmApp({
105
328
  store,
329
+ siteKeys: siteKeysFromEnv("BYOLLM_SITE_KEYS"),
106
330
  delivery: supabaseRealtimeDelivery(serviceRoleClient),
107
331
  });
108
332
  ```
@@ -118,12 +342,12 @@ everything the browser does.
118
342
  ## Two things the API makes you confront
119
343
 
120
344
  **Community results are untrusted.** A `named`/`public` result came from
121
- someone else's machine and can be anything. Every result carries provenance,
345
+ someone else's device and can be anything. Every result carries provenance,
122
346
  and `untrusted` is derived from the audience — you cannot mark volunteer
123
347
  output as first-party:
124
348
 
125
349
  ```ts
126
- const { outcome, provenance } = await app.result(jobId);
350
+ const { outcome, provenance } = await getApp().result(jobId);
127
351
  if (provenance?.untrusted) {
128
352
  // Do not render as trusted HTML. Do not feed to a privileged step.
129
353
  // Disclose where it came from.
@@ -133,7 +357,7 @@ if (provenance?.untrusted) {
133
357
  **Jobs can depend on each other.** `dependsOn: [jobId]` keeps a job
134
358
  unclaimable until its dependencies are `ok`. One field and one claim
135
359
  predicate, not a DAG engine — so the two halves of a piece of work can land on
136
- two different people's machines, in order, without your app orchestrating the
360
+ two different people's devices, in order, without your app orchestrating the
137
361
  wait.
138
362
 
139
363
  ## Certifying an adapter
@@ -146,3 +370,17 @@ A server is byollm-compatible when the kit passes. See
146
370
  [`@byollm/conformance`](../conformance).
147
371
 
148
372
  MIT
373
+
374
+ <!-- family:start -->
375
+
376
+ ## The rest of byollm
377
+
378
+ Six packages, and they are only interesting together:
379
+
380
+ - [`byollm`](https://www.npmjs.com/package/byollm) — the daemon — runs models on your own machine and answers for it
381
+ - [`@byollm/protocol`](https://www.npmjs.com/package/@byollm/protocol) — the wire: envelopes, signatures and the closed vocabularies both ends validate against
382
+ - [`@byollm/relay`](https://www.npmjs.com/package/@byollm/relay) — the broker that holds jobs between a site and a device, and can read neither
383
+ - [`@byollm/control-plane`](https://www.npmjs.com/package/@byollm/control-plane) — who may ask whom, and the policy store behind it
384
+ - [`@byollm/conformance`](https://www.npmjs.com/package/@byollm/conformance) — the kit that proves an implementation is one — including a posture audit that holds nothing but a URL
385
+
386
+ <!-- family:end -->
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()));