@byollm/relay 0.1.0-alpha.7 → 0.1.0-alpha.70

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/dist/index.d.ts CHANGED
@@ -1,144 +1,8 @@
1
- import { PublicIdentity, JobStub, SealedEnvelope } from '@byollm/protocol';
1
+ import { PublicIdentity, CapabilityMatrix, ClaimedStub, SignedGrant } from '@byollm/protocol';
2
+ import { R as RoutingStore } from './store-DPCLO12l.js';
3
+ export { A as AWAITING_PAYLOAD_MS, C as ClaimInput, G as Grant, H as HolderRefusal, P as Presence, a as RelayState, b as ReleaseReason, c as RoutedJob, d as RoutedState, r as routeKey } from './store-DPCLO12l.js';
2
4
  import { z } from 'zod';
3
5
 
4
- /**
5
- * The relay's routing state — byollm_009 §7, reachable at last.
6
- *
7
- * §7 described a state machine the direct plane could not produce. There, the
8
- * site and the upstream are the same party: it seals when it likes, and a job
9
- * is never claimed-but-unsealed. Here they are different parties, and the gap
10
- * between them is a state:
11
- *
12
- * ```
13
- * queued ──claim──▶ awaiting-payload ──sealed──▶ ready ──fetch──▶ running
14
- * ▲ │ │
15
- * └────────────────────┘ ▼
16
- * site never seals, or seals too late ok | error | canceled
17
- * ```
18
- *
19
- * The relay cannot seal, so it cannot shortcut this. A payload is encrypted
20
- * to *the device that claimed it*, and nobody knows which device that is until
21
- * the claim happens — which is precisely why claim-then-fetch makes a blind
22
- * relay possible at all. The window is the price.
23
- *
24
- * ## What the relay holds, and what it cannot
25
- *
26
- * Stubs (metadata the site chose to publish), sealed envelopes it cannot open,
27
- * and public keys. There is no field on any type in this file that could hold
28
- * a private key or a plaintext, which is `RELAY_BLIND` expressed as a data
29
- * model rather than as a policy.
30
- */
31
- /** Where a routed job is. */
32
- type RoutedState = "queued" | "awaiting-payload" | "ready" | "running" | "done";
33
- /**
34
- * How long a site has to seal after one of its jobs is claimed.
35
- *
36
- * **Distinct from the lease, and distinct from the job's TTL** — byollm_009
37
- * §7.1. Three clocks, three different questions:
38
- *
39
- * - the **TTL** asks how long the work is worth doing at all;
40
- * - the **lease** asks how long this device gets to run it;
41
- * - this asks how long we wait for a site that has gone away.
42
- *
43
- * Collapsing any pair of them looks harmless until a site restarts during a
44
- * deploy: with only a lease, the device sits politely holding a job whose
45
- * payload will never arrive, and the lease's whole minute is spent waiting on
46
- * a party that is not coming back. Short, because a site that is up answers in
47
- * milliseconds and a site that is down will not answer sooner for waiting.
48
- */
49
- declare const AWAITING_PAYLOAD_MS = 10000;
50
- /** A job the relay is routing. Metadata and ciphertext, nothing else. */
51
- interface RoutedJob {
52
- readonly id: string;
53
- /** Which site enqueued it — the party that will be asked to seal. */
54
- readonly siteId: string;
55
- /**
56
- * Everything the relay knows about the work, which is everything the site
57
- * chose to publish and not one field more (byollm_009 §6).
58
- */
59
- readonly stub: JobStub;
60
- state: RoutedState;
61
- /** Set from the claim; the site seals to these keys. */
62
- claimedBy?: {
63
- readonly runnerId: string;
64
- readonly owner: string;
65
- readonly device: PublicIdentity;
66
- readonly leaseId: string;
67
- readonly leaseExpiresAt: number;
68
- };
69
- /** When {@link AWAITING_PAYLOAD_MS} runs out for this claim. */
70
- awaitingUntil?: number;
71
- /** Sealed to the claiming device by the site. Opaque here. */
72
- payload?: SealedEnvelope;
73
- /** Sealed to the site by the device. Opaque here. */
74
- result?: SealedEnvelope;
75
- /**
76
- * The result's clear-text discriminator — byollm_009 §6.1.
77
- *
78
- * The one outcome fact the relay is given, and the reason it is given:
79
- * without it the relay cannot stop dispatching a finished job. A routing
80
- * hint and never a fact — the *site* verifies it against the sealed
81
- * outcome, because only the site can open the envelope. The relay acts on
82
- * it and is entitled to be wrong; a lying daemon costs it a dispatch
83
- * decision, not a security property.
84
- */
85
- disposition?: "ok" | "error" | "canceled";
86
- }
87
- /** A device the relay has seen recently. */
88
- interface Presence {
89
- readonly runnerId: string;
90
- readonly owner: string;
91
- readonly device: PublicIdentity;
92
- lastSeenAt: number;
93
- /** Set on revocation so the next request is refused rather than routed. */
94
- revoked: boolean;
95
- }
96
- /**
97
- * In-memory routing state.
98
- *
99
- * Deliberately not durable. The skeleton proves the protocol, and the
100
- * production hub replaces this with the closed multi-tenant router behind the
101
- * same shape (cloud_004 §9). Anything a restart loses here is a job that
102
- * returns to its site's queue — which is the behaviour a lapsed lease already
103
- * has to produce, so nothing new needs to be true for this to be safe.
104
- */
105
- declare class RelayState {
106
- #private;
107
- /** Take a stub for routing. The payload is not here and will not be. */
108
- enqueue(input: {
109
- id: string;
110
- siteId: string;
111
- stub: JobStub;
112
- }): RoutedJob;
113
- job(jobId: string): RoutedJob | undefined;
114
- jobs(): RoutedJob[];
115
- /** Jobs a site must seal for, right now. */
116
- awaiting(siteId: string): RoutedJob[];
117
- /** Sealed results waiting to go home. */
118
- finished(siteId: string): RoutedJob[];
119
- seen(presence: Omit<Presence, "revoked">): Presence;
120
- presence(runnerId: string): Presence | undefined;
121
- everyone(): Presence[];
122
- /**
123
- * Return a job to the queue, forgetting the claim.
124
- *
125
- * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
126
- * why the awaiting-payload timeout is cheap to fire: the worst case is that
127
- * a device did nothing for ten seconds and another one gets a turn.
128
- */
129
- requeue(job: RoutedJob): void;
130
- /**
131
- * Fire whatever the clock says is due, and report it.
132
- *
133
- * Returns the jobs it requeued so a caller can log or surface them — a
134
- * timeout that fires invisibly is indistinguishable from a job that was
135
- * never claimed, and those want very different debugging.
136
- */
137
- sweep(now: number): RoutedJob[];
138
- }
139
-
140
- declare function debugPage(state: RelayState, now: number): string;
141
-
142
6
  /**
143
7
  * What the relay is told about the world — cloud_004 §14.
144
8
  *
@@ -171,6 +35,42 @@ declare function debugPage(state: RelayState, now: number): string;
171
35
  * can open anything, and {@link RelayFixture} has no field where one could be
172
36
  * put — `RELAY_BLIND` as a type, not as a promise.
173
37
  */
38
+ /**
39
+ * A site the control plane registered and domain-verified — cloud_004 §5.
40
+ *
41
+ * **The one authority for a site's public identity.** It used to be inlined on
42
+ * every consent record, which meant a site's key had as many homes as it had
43
+ * users and nothing checked they agreed — the exact shape this project has now
44
+ * found in a version constant, a clock read, an envelope deadline, a reseal
45
+ * implementation, a package list and a docs page. Consents now reference a
46
+ * site by id and the key is looked up here.
47
+ *
48
+ * The relay needs it for two things it cannot do without:
49
+ *
50
+ * 1. **Telling a daemon who to pin** at pairing — the key that makes relayed
51
+ * work unforgeable, since the relay holds no key that could produce it.
52
+ * 2. **Authenticating the site plane.** A site calls a relay the way a daemon
53
+ * does, signing with this identity, and this is the key those signatures
54
+ * are checked against.
55
+ */
56
+ declare const SiteRecord: z.ZodObject<{
57
+ siteId: z.ZodString;
58
+ site: z.ZodObject<{
59
+ identity: z.ZodString;
60
+ encryption: z.ZodString;
61
+ encryptionSig: z.ZodString;
62
+ }, z.core.$strict>;
63
+ succeeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
64
+ identity: z.ZodObject<{
65
+ identity: z.ZodString;
66
+ encryption: z.ZodString;
67
+ encryptionSig: z.ZodString;
68
+ }, z.core.$strict>;
69
+ signature: z.ZodString;
70
+ }, z.core.$strict>>>;
71
+ retiringUntil: z.ZodOptional<z.ZodNumber>;
72
+ }, z.core.$strict>;
73
+ type SiteRecord = z.infer<typeof SiteRecord>;
174
74
  /**
175
75
  * A user's decision to let one site use their compute — cloud_004 §3.
176
76
  *
@@ -181,11 +81,7 @@ declare function debugPage(state: RelayState, now: number): string;
181
81
  declare const ConsentRecord: z.ZodObject<{
182
82
  owner: z.ZodString;
183
83
  siteId: z.ZodString;
184
- site: z.ZodObject<{
185
- identity: z.ZodString;
186
- encryption: z.ZodString;
187
- encryptionSig: z.ZodString;
188
- }, z.core.$strict>;
84
+ paused: z.ZodDefault<z.ZodBoolean>;
189
85
  }, z.core.$strict>;
190
86
  type ConsentRecord = z.infer<typeof ConsentRecord>;
191
87
  /**
@@ -231,14 +127,27 @@ declare const RevocationRecord: z.ZodObject<{
231
127
  }, z.core.$strict>;
232
128
  type RevocationRecord = z.infer<typeof RevocationRecord>;
233
129
  declare const RelayFixture: z.ZodObject<{
234
- consents: z.ZodArray<z.ZodObject<{
235
- owner: z.ZodString;
130
+ sites: z.ZodDefault<z.ZodArray<z.ZodObject<{
236
131
  siteId: z.ZodString;
237
132
  site: z.ZodObject<{
238
133
  identity: z.ZodString;
239
134
  encryption: z.ZodString;
240
135
  encryptionSig: z.ZodString;
241
136
  }, z.core.$strict>;
137
+ succeeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
138
+ identity: z.ZodObject<{
139
+ identity: z.ZodString;
140
+ encryption: z.ZodString;
141
+ encryptionSig: z.ZodString;
142
+ }, z.core.$strict>;
143
+ signature: z.ZodString;
144
+ }, z.core.$strict>>>;
145
+ retiringUntil: z.ZodOptional<z.ZodNumber>;
146
+ }, z.core.$strict>>>;
147
+ consents: z.ZodArray<z.ZodObject<{
148
+ owner: z.ZodString;
149
+ siteId: z.ZodString;
150
+ paused: z.ZodDefault<z.ZodBoolean>;
242
151
  }, z.core.$strict>>;
243
152
  devices: z.ZodDefault<z.ZodArray<z.ZodObject<{
244
153
  owner: z.ZodString;
@@ -275,6 +184,13 @@ declare class Projection {
275
184
  constructor(fixture?: RelayFixture);
276
185
  /** Replace the projection wholesale — the control plane pushed a new one. */
277
186
  replace(fixture: RelayFixture): void;
187
+ /**
188
+ * The site this id names, if the control plane registered it.
189
+ *
190
+ * The only source of a site's public identity in this package. Everything
191
+ * that pins, verifies or seals to a site starts here.
192
+ */
193
+ siteFor(siteId: string): SiteRecord | null;
278
194
  /**
279
195
  * The device this runner id names, if a human approved it.
280
196
  *
@@ -284,8 +200,135 @@ declare class Projection {
284
200
  deviceFor(runnerId: string): DeviceRecord | null;
285
201
  /** The device approved for these exact keys, if any. */
286
202
  deviceByFingerprint(identityPublic: string): DeviceRecord | null;
287
- /** The consent binding this owner to this site, if it exists and stands. */
203
+ /**
204
+ * The consent binding this owner to this site, if it exists and stands.
205
+ *
206
+ * **Liveness, not routing.** A paused consent is returned here: the
207
+ * relationship exists, the daemon is not revoked, the pairing stands. Ask
208
+ * {@link Projection.mayRouteFor} before moving anybody's work — the two
209
+ * questions have different answers and one method answering both is how a
210
+ * paused user would quietly start routing again.
211
+ */
288
212
  consentFor(owner: string, siteId: string): ConsentRecord | null;
213
+ /**
214
+ * Every site this owner may route with — cloud_009 §3.
215
+ *
216
+ * The set a pairing covers, and the set a claim will filter on. Consent
217
+ * decides it, which is the sentence the whole design rests on: a site
218
+ * appears here because a human clicked, never because a site asked to be
219
+ * here and never because a daemon named it.
220
+ *
221
+ * **Paused sites are here, and that is deliberate** — cloud_008 finding 48
222
+ * as ratified. A paused consent routes nothing and keeps its pin: the
223
+ * relationship stands, the key the daemon compared a fingerprint of stays
224
+ * pinned, and re-consenting never costs a re-pair. Written the other way
225
+ * round first, and three of the paused tests failed by refusing to pair at
226
+ * all — which is the trap the finding is about, arriving through the door
227
+ * marked "be stricter".
228
+ *
229
+ * So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
230
+ * questions with different answers, kept apart for the same reason
231
+ * `consentFor` and `mayRouteFor` are: one method answering both is how a
232
+ * paused user quietly starts routing again, or quietly loses their machine.
233
+ *
234
+ * Sorted by site id so two calls with the same projection produce the same
235
+ * answer: this ends up in a pairings file and in a fingerprint list a human
236
+ * compares by eye, and an order that drifts between polls is a diff nobody
237
+ * can read.
238
+ */
239
+ sitesFor(owner: string): SiteRecord[];
240
+ /**
241
+ * Which registered site owns this identity key id?
242
+ *
243
+ * A stub names its site by *key id* (Amendment A §A.3) so a daemon can
244
+ * check it against a pinned key without a lookup. A control plane knows
245
+ * sites by their account id. This is the one place that holds both, so it
246
+ * is the one place that joins them — a control plane asked to accept key
247
+ * ids would need its own copy of the registry.
248
+ *
249
+ * `null` for a key id no registered site carries, which is a projection
250
+ * that is behind rather than a job that is wrong.
251
+ */
252
+ siteIdForKey(keyId: string): string | null;
253
+ /**
254
+ * May this owner's work move for this site, right now?
255
+ *
256
+ * Consent exists, was not revoked, and is not paused. The routing question,
257
+ * kept apart from {@link Projection.consentFor}'s liveness one so that a
258
+ * caller has to pick which it means.
259
+ */
260
+ mayRouteFor(owner: string, siteId: string): boolean;
261
+ /**
262
+ * Has this owner's relationship *ended* — V1-2?
263
+ *
264
+ * Not "is there nothing to serve". Those were one question until the pre-v1
265
+ * review pulled them apart, and the difference is a machine's pinned keys:
266
+ * an empty answer made the daemon stop, cancel everything and **delete its
267
+ * pairings file**, so a projection that arrived empty or half-written — one
268
+ * bad control-plane push — cost every daemon its pins and every user a
269
+ * re-pair they never asked for.
270
+ *
271
+ * Revocation is a thing somebody did, and this asks for the evidence of it:
272
+ * a revocation record for this owner, and nothing left standing. A
273
+ * projection that simply knows nothing says nothing — the relay answers
274
+ * normally, the daemon serves nobody, and the pairing survives to be
275
+ * correct again when the next push lands.
276
+ *
277
+ * The `revoked` list exists precisely for this and was consulted by
278
+ * nothing. Its own doc said why: "the row is gone" and "the row was
279
+ * revoked" are different answers, and only one of them is a decision.
280
+ */
281
+ revokedOutright(owner: string): boolean;
282
+ /** Whether this pair is consented and paused — what heartbeat reports. */
283
+ pausedFor(owner: string, siteId: string): boolean;
284
+ /**
285
+ * Every (site, owner) route this device may run — cloud_009 §3.
286
+ *
287
+ * The claim filter, collapsed to data a store can match on. `routableOwners`
288
+ * was this for one site; the hub needs it for the set, and the shape had to
289
+ * change rather than repeat, because **a set of sites and a set of owners
290
+ * multiply**. A device whose owner consented to site A, serving a roster
291
+ * member who consented to site B, appears in both sets and has no consented
292
+ * route between them. Pairs cannot express a route nobody agreed to.
293
+ *
294
+ * Both halves of the rule are here, and neither was enforced before finding
295
+ * 48's work:
296
+ *
297
+ * - **This machine's owner** must have a live consent for the site, or
298
+ * nothing of that site's runs here at all — including a roster member's
299
+ * work. The roster says whose jobs may land on this machine; consent says
300
+ * whether this machine is available to that site.
301
+ * - **Each job's owner** must have one too. That check did not exist:
302
+ * consent was enforced by the daemon plane's blanket revoked guard, which
303
+ * asks only about the claiming device's owner, so a roster member who
304
+ * never consented to a site could have their work claimed by their admin's
305
+ * machine — `CONSENT_BEFORE_ROUTE` read the other way round.
306
+ */
307
+ routesFor(deviceOwner: string): Set<string>;
308
+ /**
309
+ * Every owner whose work this device's owner may run, as a list.
310
+ *
311
+ * The same question {@link mayRunFor} answers, asked in the direction a
312
+ * *store* can use. That difference is the crux of making `claim` atomic
313
+ * (cloud_006 §3.2).
314
+ *
315
+ * Today `claim` scans every job and calls `mayRunFor` per candidate, which
316
+ * works because the projection is a local object. A shared routing store
317
+ * cannot do that: the filter has to travel to the store, and a predicate
318
+ * does not travel — you cannot send a closure to Valkey. So the projection
319
+ * is collapsed to **data** here and handed over as a set the store can
320
+ * match on.
321
+ *
322
+ * That the collapse is possible at all is a property of the design worth
323
+ * noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
324
+ * computation over the jobs. If it ever became job-dependent — "may run
325
+ * work of this size", say — an atomic claim would stop being expressible,
326
+ * and that is the moment to argue rather than to add a parameter.
327
+ *
328
+ * The owner is always included: a device runs its owner's work, and the
329
+ * relay checks that before it checks a roster.
330
+ */
331
+ ownersRunnableBy(deviceOwner: string): string[];
289
332
  /**
290
333
  * May this device's owner run work belonging to `jobOwner`?
291
334
  *
@@ -298,6 +341,173 @@ declare class Projection {
298
341
  mayRunFor(deviceOwner: string, jobOwner: string): boolean;
299
342
  }
300
343
 
344
+ /**
345
+ * Pending pairing codes — cloud_009, the cloud-pairing flow.
346
+ *
347
+ * `byollm connect` speaks the device-code flow: ask for a code, show it, poll
348
+ * while a human approves it in a browser. A relay had no way to hold that
349
+ * pending state, so cloud pairing was never implemented — the hub accepted
350
+ * only the shape where the device is *already* approved, and nothing in the
351
+ * control plane created device rows at all. Every test passed because they
352
+ * drive direct mode or seed the row with a service key: the checks proved the
353
+ * parts and never the seam.
354
+ *
355
+ * ## Why the relay holds the code, and the control plane holds the decision
356
+ *
357
+ * The code is a short-lived handle on an *assertion* — "this keypair would
358
+ * like to be a machine" — and the relay is allowed to hold assertions. The
359
+ * approval is a human looking at a fingerprint, which belongs to the control
360
+ * plane where that human is signed in.
361
+ *
362
+ * So nothing here approves anything. The daemon's poll asks whether the
363
+ * control plane's projection now contains this device as approved, and the
364
+ * answer comes from the projection rather than from a flag somebody set here.
365
+ * That is what keeps the fence intact in both directions: the hub never
366
+ * writes to the control plane, and the control plane never writes to the hub.
367
+ *
368
+ * ## What a code is worth on its own
369
+ *
370
+ * Nothing. Holding a device code lets you ask "has anyone approved this
371
+ * keypair yet", and the answer is only ever yes for a keypair whose owner
372
+ * approved it by eye. Stolen mid-flight it grants no access, which is why it
373
+ * can be a URL-safe string a person reads aloud rather than a credential.
374
+ */
375
+ /** What the relay remembers between `start` and `poll`. */
376
+ interface PendingPairing {
377
+ /** The secret the daemon polls with. Never shown to a human. */
378
+ readonly deviceCode: string;
379
+ /** The short code a person reads and types into the dashboard. */
380
+ readonly userCode: string;
381
+ /** The keys the daemon presented. What a human is about to approve. */
382
+ readonly device: PublicIdentity;
383
+ /**
384
+ * What the machine said it can run, as advertised when it asked to pair.
385
+ *
386
+ * Held so the approval screen can show a person what they are approving,
387
+ * and so presence has an answer the moment the device appears rather than
388
+ * one heartbeat later. It is a claim, like everything else in this record —
389
+ * the heartbeat is the authority and replaces it within seconds.
390
+ */
391
+ readonly capabilities: CapabilityMatrix;
392
+ /** Label the daemon offered, for the approval screen. */
393
+ readonly label: string;
394
+ readonly platform: string;
395
+ /** Epoch ms. After this the code is gone, approved or not. */
396
+ readonly expiresAt: number;
397
+ }
398
+ /**
399
+ * What happened when a code was offered for storage.
400
+ *
401
+ * `put` can refuse, and the reason it can is the whole of the rate-limit
402
+ * story on this surface: **anybody can ask to pair.** That is not a bug — a
403
+ * machine with no pairing has no credential to present — but it means a
404
+ * stranger with a script can mint pending codes in a loop, and each one
405
+ * occupies memory in a shared store for ten minutes. Without a ceiling the
406
+ * only limit is somebody's patience.
407
+ *
408
+ * So the store has a capacity and says so, and the daemon is told to try
409
+ * again shortly rather than given a code that crowds out a real one. A cap is
410
+ * a blunt instrument — under a flood, a person pairing a laptop is refused
411
+ * alongside the attacker — but a refusal that resolves in ten minutes is a
412
+ * better failure than a hub that stops routing. Per-IP limits belong at the
413
+ * edge, where the IP actually is.
414
+ */
415
+ type PutResult = "stored" | "at-capacity";
416
+ /**
417
+ * What a caller is told when pairings are being refused for load.
418
+ *
419
+ * Exported because it is said in two places by two different limits. This
420
+ * package says it when the store is at capacity; a deployment that adds a
421
+ * per-IP budget in front (the hub does — cloud_014) says it when one source
422
+ * has spent its share. **One sentence for one situation, whichever limit
423
+ * produced it**: the person reading it in a terminal is told to try again
424
+ * shortly, and which of the two bit is not a distinction they can act on.
425
+ *
426
+ * It lived inline here and the hub kept a copy, which is the one-value-two-
427
+ * names defect this codebase keeps finding — and the copy that drifts would
428
+ * drift silently, because both sentences would be plausible.
429
+ */
430
+ declare const PAIRING_BUSY_MESSAGE = "too many pairings are in progress right now \u2014 try again in a few minutes";
431
+ interface PairingCodes {
432
+ put(pending: PendingPairing): Promise<PutResult>;
433
+ /** By the secret the daemon holds. */
434
+ byDeviceCode(deviceCode: string): Promise<PendingPairing | undefined>;
435
+ /** By the short code a human typed. */
436
+ byUserCode(userCode: string): Promise<PendingPairing | undefined>;
437
+ /** After a successful pairing, so a code is single-use. */
438
+ drop(deviceCode: string): Promise<void>;
439
+ }
440
+ declare function newUserCode(): string;
441
+ /** The secret half. Long and URL-safe; never shown to anybody. */
442
+ declare const newDeviceCode: () => string;
443
+ /** How long a person has to walk to their browser and type eight characters. */
444
+ declare const PAIRING_CODE_TTL_MS: number;
445
+ /**
446
+ * How many pairings may be in flight at once, across a whole relay.
447
+ *
448
+ * Sized against reality rather than fear: a pairing takes under a minute of
449
+ * human attention, so five hundred outstanding at the same instant is a
450
+ * number this product will not reach honestly for a long time — and one an
451
+ * attacker reaches in a second. Small enough to bound the store, large enough
452
+ * that nobody legitimate meets it.
453
+ */
454
+ declare const MAX_OUTSTANDING_PAIRINGS = 500;
455
+ /**
456
+ * The in-memory implementation, for the reference relay and its tests.
457
+ *
458
+ * The hub replaces it with one backed by Valkey, because a hub is two
459
+ * replicas and a code minted on one must be pollable on the other — the same
460
+ * reason its routing store is not a `Map`.
461
+ */
462
+ declare class MemoryPairingCodes implements PairingCodes {
463
+ #private;
464
+ constructor(now?: () => number, capacity?: number);
465
+ put(pending: PendingPairing): Promise<PutResult>;
466
+ byDeviceCode(deviceCode: string): Promise<PendingPairing | undefined>;
467
+ byUserCode(userCode: string): Promise<PendingPairing | undefined>;
468
+ drop(deviceCode: string): Promise<void>;
469
+ }
470
+
471
+ /**
472
+ * What the relay asks a control plane, as one named shape.
473
+ *
474
+ * Spelled out twice — here and on `RelayOptions` — until adding `siteKey`
475
+ * made one of them wrong and the build caught it. That was luck: the two are
476
+ * structurally compared, so a field added to the *caller's* copy alone would
477
+ * have been accepted silently and the grant would have carried nothing.
478
+ */
479
+ type GrantAuthor = (input: {
480
+ readonly job: ClaimedStub;
481
+ /** The site's id in the control plane's namespace, for its policy read. */
482
+ readonly siteId: string;
483
+ /**
484
+ * The same site as the stub names it — the key id the device pinned.
485
+ *
486
+ * Carried, never derived. This is the value that gets signed, and the one a
487
+ * device can compare against `stub.site` without a lookup and without
488
+ * trusting the party that routed it.
489
+ */
490
+ readonly siteKey: string;
491
+ readonly purpose?: string;
492
+ readonly owner: string;
493
+ readonly runnerId: string;
494
+ readonly capabilities: CapabilityMatrix;
495
+ }) => Promise<GrantDecision> | GrantDecision;
496
+
497
+ declare function debugPage(state: RoutingStore, now: number,
498
+ /**
499
+ * Asked whether each device's owner still consents — cloud_008 §2.3.
500
+ *
501
+ * The page used to read a `revoked` boolean off presence. That flag was a
502
+ * stored copy of a fact the projection owns, and it is gone; the page asks
503
+ * the authority instead, which is also the only thing that stays correct
504
+ * when one daemon serves several sites.
505
+ */
506
+ routesFor?: {
507
+ siteId: string;
508
+ consents: (owner: string) => boolean;
509
+ }): Promise<string>;
510
+
301
511
  /**
302
512
  * `@byollm/relay` — the reference relay (cloud_004 §14).
303
513
  *
@@ -323,6 +533,30 @@ declare class Projection {
323
533
  * able to read a payload is to change its types, which is a review someone
324
534
  * would have to justify rather than a line someone could slip in.
325
535
  */
536
+ /**
537
+ * What a control plane answers when a relay asks about one job.
538
+ *
539
+ * Declared here rather than imported, and deliberately narrower than what
540
+ * `@byollm/control-plane` returns: a relay needs to know whether it got a
541
+ * grant and whether a refusal is forever, and nothing else. Stating only that
542
+ * keeps the two packages independent — a relay can be wired to any control
543
+ * plane, and the reference engine satisfies this by having more, not less.
544
+ *
545
+ * `reason` is for the log. The relay never branches on it, because a relay
546
+ * that acted differently per reason would be a second implementation of a
547
+ * policy it does not own.
548
+ */
549
+ type GrantDecision = {
550
+ readonly granted: SignedGrant;
551
+ readonly declined?: undefined;
552
+ } | {
553
+ readonly granted?: undefined;
554
+ readonly declined: {
555
+ /** Never offer this job to this device again. */
556
+ readonly permanent: boolean;
557
+ readonly reason?: string;
558
+ };
559
+ };
326
560
  interface RelayOptions {
327
561
  /**
328
562
  * Which site this relay routes for.
@@ -330,20 +564,129 @@ interface RelayOptions {
330
564
  * One, in the skeleton. Multi-tenant routing is the closed piece
331
565
  * (cloud_004 §9), and it replaces this field rather than extending it.
332
566
  */
333
- readonly siteId: string;
334
- /** Consent and rosters, projected from the control plane. */
567
+ /** Consent and routing, projected from the control plane. */
335
568
  readonly fixture?: RelayFixture;
569
+ /**
570
+ * The control plane's grant-signing public key — Amendment J.
571
+ *
572
+ * Handed to daemons at pairing, and the thing every grant is checked
573
+ * against. Configuring it without {@link RelayOptions.authorGrant} is
574
+ * refused at construction: a device told to expect signed grants and then
575
+ * sent none refuses every job, and it would do so with no signal here.
576
+ */
577
+ readonly controlPlanePublic?: string | undefined;
578
+ /**
579
+ * Whether a purpose can be satisfied for this person, asked at enqueue.
580
+ *
581
+ * The relay does not hold the answer and must not: one that filtered on
582
+ * mappings would hold the mapping, which is the one thing it cannot have. So
583
+ * it asks whoever does — in practice the control plane, which already
584
+ * decides this at claim, a moment later.
585
+ *
586
+ * Optional. A relay without it refuses nothing, which is a supported
587
+ * arrangement and one an operator must be able to see they are in: say so at
588
+ * boot and on the health surface, because a check that quietly is not there
589
+ * reads as a check that passed.
590
+ */
591
+ readonly satisfiable?: (query: {
592
+ readonly siteId: string;
593
+ readonly owner: string;
594
+ readonly purpose: string | undefined;
595
+ readonly kind: string;
596
+ }) => Promise<{
597
+ readonly verdict: "ok" | "not-declared" | "unmapped";
598
+ }>;
599
+ /**
600
+ * Author a grant for one claimed job — Amendment J.
601
+ *
602
+ * **The relay asks; it does not decide.** Everything a grant asserts —
603
+ * whose job this is, whether they are still a member, which of the owner's
604
+ * services their mapping resolves to — is the control plane's knowledge,
605
+ * and this callback is the seam between the two. A relay wired to a
606
+ * deployment that has no control plane simply has no callback, and its
607
+ * devices serve their owners alone.
608
+ *
609
+ * Declining says whether the refusal is **permanent**, and that is the
610
+ * whole reason this returns a shape rather than `SignedGrant | undefined`.
611
+ * A relay releases a declined job, and a release can carry `refused`, which
612
+ * means never offer this job to this device again. "This person was removed
613
+ * from the team" is forever — removal stops queued claims, per hole 1.
614
+ * "Their mapping resolved to another of your machines" is emphatically not:
615
+ * marking that permanently would mean the job could never reach the device
616
+ * it was always meant for, and nothing would ever report it.
617
+ *
618
+ * The capability matrix is passed because resolution needs it — the control
619
+ * plane chooses from what this device actually advertised, never from a
620
+ * name it invented. Until byollm_016 Amendment L lands, "resolution" is the
621
+ * job's own selection or the device's default; after it, the user's
622
+ * per-purpose mapping. The seam does not change.
623
+ */
624
+ readonly authorGrant?: GrantAuthor;
336
625
  /** How long a claim is good for. */
337
626
  readonly leaseMs?: number;
338
627
  /** Injectable clock, so tests move time instead of sleeping. */
339
628
  readonly now?: () => number;
629
+ /**
630
+ * Where pending pairing codes live — cloud_009's device-code flow.
631
+ *
632
+ * Defaults to an in-memory store, which is right for the reference relay
633
+ * and wrong for a hub: two replicas mean a code minted on one must be
634
+ * pollable on the other, the same reason the routing store is not a `Map`.
635
+ */
636
+ readonly pairingCodes?: PairingCodes;
637
+ /**
638
+ * Where a human approves a code. The control plane's own URL.
639
+ *
640
+ * Given rather than derived: the relay cannot approve anything, because
641
+ * approving is looking at a fingerprint while signed in and that session
642
+ * lives in the dashboard. Absent, the device-code flow is refused as
643
+ * unsupported rather than pointed somewhere useless.
644
+ */
645
+ readonly verificationUrl?: string;
340
646
  /** Where the daemon plane is mounted. */
341
647
  readonly basePath?: string;
648
+ /**
649
+ * Serve `/debug`, which is off unless somebody asks for it.
650
+ *
651
+ * The page shows every routed job for a site, its state, who claimed it and
652
+ * how long its timers have left. It shows no prompt or result text — the
653
+ * relay does not have them — and it is genuinely useful when a route is
654
+ * behaving strangely.
655
+ *
656
+ * It is also, on anything reachable from the internet, an anonymous read of
657
+ * exactly the metadata the site plane exists to protect. That was finding
658
+ * eleven, found by curling a deployed hub. The hub refuses the route
659
+ * outright; this package used to serve it by default and leave `D005` to
660
+ * warn whoever deployed it, which is a default that fails safe only if
661
+ * somebody reads the audit.
662
+ *
663
+ * So: off, and per-site when on (cloud_009 §3 — the debug page is per-site
664
+ * or it is nothing). `D005` still fails for a relay that turned it on,
665
+ * which is the audit doing its job for an operator who made a choice rather
666
+ * than warning everybody about a default.
667
+ */
668
+ readonly debug?: boolean;
669
+ /**
670
+ * Where routing state lives — cloud_006.
671
+ *
672
+ * Defaults to an in-process {@link RelayState}, which is correct for one
673
+ * replica and is what this package ships. A hub running more than one
674
+ * replica supplies a shared implementation of {@link RoutingStore} instead;
675
+ * `packages/relay/test/two-replicas.test.ts` is why that is not optional.
676
+ *
677
+ * **The implementation is deliberately not in this package.** A Valkey
678
+ * client is a dependency every consumer would carry to get a feature only a
679
+ * multi-replica deployment uses, and the production hub is the closed piece
680
+ * (cloud_001). What ships here is the interface, the reference
681
+ * implementation, and the tests that say what an implementation must
682
+ * guarantee.
683
+ */
684
+ readonly store?: RoutingStore;
342
685
  }
343
686
  /** A running relay: one fetch handler, two planes, one debug page. */
344
687
  declare class Relay {
345
688
  #private;
346
- readonly state: RelayState;
689
+ readonly state: RoutingStore;
347
690
  readonly projection: Projection;
348
691
  constructor(options: RelayOptions);
349
692
  /** Replace the projection — a control-plane push, or a fixture edit. */
@@ -357,11 +700,11 @@ declare class Relay {
357
700
  * site vanished should return to the queue without waiting for someone to
358
701
  * ask about it.
359
702
  */
360
- sweep(): {
703
+ sweep(): Promise<{
361
704
  requeued: string[];
362
- };
705
+ }>;
363
706
  /** The whole HTTP surface. */
364
707
  handle(request: Request): Promise<Response>;
365
708
  }
366
709
 
367
- export { AWAITING_PAYLOAD_MS, ConsentRecord, DeviceRecord, EMPTY_FIXTURE, type Presence, Projection, Relay, RelayFixture, RelayFixture as RelayFixtureSchema, type RelayOptions, RelayState, RevocationRecord, RosterRecord, type RoutedJob, type RoutedState, debugPage };
710
+ export { ConsentRecord, DeviceRecord, EMPTY_FIXTURE, type GrantDecision, MAX_OUTSTANDING_PAIRINGS, MemoryPairingCodes, PAIRING_BUSY_MESSAGE, PAIRING_CODE_TTL_MS, type PairingCodes, type PendingPairing, Projection, type PutResult, Relay, RelayFixture, RelayFixture as RelayFixtureSchema, type RelayOptions, RevocationRecord, RosterRecord, RoutingStore, SiteRecord, debugPage, newDeviceCode, newUserCode };