@byollm/relay 0.1.0-alpha.8 → 0.1.0-alpha.80

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,161 +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
- /**
108
- * Take a stub for routing. The payload is not here and will not be.
109
- *
110
- * **Idempotent by job id, and that is a security property rather than a
111
- * convenience.** Site-plane calls are authenticated by signature, and
112
- * byollm_009 §4.2's argument for signing the request instead of a
113
- * server-issued nonce rests entirely on every write being idempotent per the
114
- * instance it names. This one was not: re-enqueueing a known id built a
115
- * fresh `queued` job over the top of the old one, discarding a live claim,
116
- * its lease and any payload the site had already sealed to a device. A
117
- * replayed enqueue inside the two-minute freshness window was therefore a
118
- * way to yank a job back from the machine running it — the `release` bug of
119
- * §4.2, rediscovered on the other plane.
120
- *
121
- * So a known id returns what is already routing, unchanged. A site that
122
- * restarts and republishes its queue is the normal case, and it must not
123
- * disturb work in flight.
124
- */
125
- enqueue(input: {
126
- id: string;
127
- siteId: string;
128
- stub: JobStub;
129
- }): RoutedJob;
130
- job(jobId: string): RoutedJob | undefined;
131
- jobs(): RoutedJob[];
132
- /** Jobs a site must seal for, right now. */
133
- awaiting(siteId: string): RoutedJob[];
134
- /** Sealed results waiting to go home. */
135
- finished(siteId: string): RoutedJob[];
136
- seen(presence: Omit<Presence, "revoked">): Presence;
137
- presence(runnerId: string): Presence | undefined;
138
- everyone(): Presence[];
139
- /**
140
- * Return a job to the queue, forgetting the claim.
141
- *
142
- * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
143
- * why the awaiting-payload timeout is cheap to fire: the worst case is that
144
- * a device did nothing for ten seconds and another one gets a turn.
145
- */
146
- requeue(job: RoutedJob): void;
147
- /**
148
- * Fire whatever the clock says is due, and report it.
149
- *
150
- * Returns the jobs it requeued so a caller can log or surface them — a
151
- * timeout that fires invisibly is indistinguishable from a job that was
152
- * never claimed, and those want very different debugging.
153
- */
154
- sweep(now: number): RoutedJob[];
155
- }
156
-
157
- declare function debugPage(state: RelayState, now: number): string;
158
-
159
6
  /**
160
7
  * What the relay is told about the world — cloud_004 §14.
161
8
  *
@@ -213,6 +60,15 @@ declare const SiteRecord: z.ZodObject<{
213
60
  encryption: z.ZodString;
214
61
  encryptionSig: z.ZodString;
215
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>;
216
72
  }, z.core.$strict>;
217
73
  type SiteRecord = z.infer<typeof SiteRecord>;
218
74
  /**
@@ -225,6 +81,7 @@ type SiteRecord = z.infer<typeof SiteRecord>;
225
81
  declare const ConsentRecord: z.ZodObject<{
226
82
  owner: z.ZodString;
227
83
  siteId: z.ZodString;
84
+ paused: z.ZodDefault<z.ZodBoolean>;
228
85
  }, z.core.$strict>;
229
86
  type ConsentRecord = z.infer<typeof ConsentRecord>;
230
87
  /**
@@ -261,6 +118,7 @@ declare const DeviceRecord: z.ZodObject<{
261
118
  encryption: z.ZodString;
262
119
  encryptionSig: z.ZodString;
263
120
  }, z.core.$strict>;
121
+ revoked: z.ZodOptional<z.ZodBoolean>;
264
122
  }, z.core.$strict>;
265
123
  type DeviceRecord = z.infer<typeof DeviceRecord>;
266
124
  /** A revoked route, named by its parts. */
@@ -277,10 +135,20 @@ declare const RelayFixture: z.ZodObject<{
277
135
  encryption: z.ZodString;
278
136
  encryptionSig: z.ZodString;
279
137
  }, z.core.$strict>;
138
+ succeeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
139
+ identity: z.ZodObject<{
140
+ identity: z.ZodString;
141
+ encryption: z.ZodString;
142
+ encryptionSig: z.ZodString;
143
+ }, z.core.$strict>;
144
+ signature: z.ZodString;
145
+ }, z.core.$strict>>>;
146
+ retiringUntil: z.ZodOptional<z.ZodNumber>;
280
147
  }, z.core.$strict>>>;
281
148
  consents: z.ZodArray<z.ZodObject<{
282
149
  owner: z.ZodString;
283
150
  siteId: z.ZodString;
151
+ paused: z.ZodDefault<z.ZodBoolean>;
284
152
  }, z.core.$strict>>;
285
153
  devices: z.ZodDefault<z.ZodArray<z.ZodObject<{
286
154
  owner: z.ZodString;
@@ -290,6 +158,7 @@ declare const RelayFixture: z.ZodObject<{
290
158
  encryption: z.ZodString;
291
159
  encryptionSig: z.ZodString;
292
160
  }, z.core.$strict>;
161
+ revoked: z.ZodOptional<z.ZodBoolean>;
293
162
  }, z.core.$strict>>>;
294
163
  rosters: z.ZodDefault<z.ZodArray<z.ZodObject<{
295
164
  id: z.ZodString;
@@ -333,8 +202,144 @@ declare class Projection {
333
202
  deviceFor(runnerId: string): DeviceRecord | null;
334
203
  /** The device approved for these exact keys, if any. */
335
204
  deviceByFingerprint(identityPublic: string): DeviceRecord | null;
336
- /** The consent binding this owner to this site, if it exists and stands. */
205
+ /**
206
+ * The consent binding this owner to this site, if it exists and stands.
207
+ *
208
+ * **Liveness, not routing.** A paused consent is returned here: the
209
+ * relationship exists, the daemon is not revoked, the pairing stands. Ask
210
+ * {@link Projection.mayRouteFor} before moving anybody's work — the two
211
+ * questions have different answers and one method answering both is how a
212
+ * paused user would quietly start routing again.
213
+ */
337
214
  consentFor(owner: string, siteId: string): ConsentRecord | null;
215
+ /**
216
+ * Every site this owner may route with — cloud_009 §3.
217
+ *
218
+ * The set a pairing covers, and the set a claim will filter on. Consent
219
+ * decides it, which is the sentence the whole design rests on: a site
220
+ * appears here because a human clicked, never because a site asked to be
221
+ * here and never because a daemon named it.
222
+ *
223
+ * **Paused sites are here, and that is deliberate** — cloud_008 finding 48
224
+ * as ratified. A paused consent routes nothing and keeps its pin: the
225
+ * relationship stands, the key the daemon compared a fingerprint of stays
226
+ * pinned, and re-consenting never costs a re-pair. Written the other way
227
+ * round first, and three of the paused tests failed by refusing to pair at
228
+ * all — which is the trap the finding is about, arriving through the door
229
+ * marked "be stricter".
230
+ *
231
+ * So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
232
+ * questions with different answers, kept apart for the same reason
233
+ * `consentFor` and `mayRouteFor` are: one method answering both is how a
234
+ * paused user quietly starts routing again, or quietly loses their machine.
235
+ *
236
+ * Sorted by site id so two calls with the same projection produce the same
237
+ * answer: this ends up in a pairings file and in a fingerprint list a human
238
+ * compares by eye, and an order that drifts between polls is a diff nobody
239
+ * can read.
240
+ */
241
+ sitesFor(owner: string): SiteRecord[];
242
+ /**
243
+ * Which registered site owns this identity key id?
244
+ *
245
+ * A stub names its site by *key id* (Amendment A §A.3) so a daemon can
246
+ * check it against a pinned key without a lookup. A control plane knows
247
+ * sites by their account id. This is the one place that holds both, so it
248
+ * is the one place that joins them — a control plane asked to accept key
249
+ * ids would need its own copy of the registry.
250
+ *
251
+ * `null` for a key id no registered site carries, which is a projection
252
+ * that is behind rather than a job that is wrong.
253
+ */
254
+ siteIdForKey(keyId: string): string | null;
255
+ /**
256
+ * May this owner's work move for this site, right now?
257
+ *
258
+ * Consent exists, was not revoked, and is not paused. The routing question,
259
+ * kept apart from {@link Projection.consentFor}'s liveness one so that a
260
+ * caller has to pick which it means.
261
+ */
262
+ mayRouteFor(owner: string, siteId: string): boolean;
263
+ /**
264
+ * Has *this device* been revoked — ruled 2026-09-03?
265
+ *
266
+ * This method has now been wrong in both directions, which is why it reads
267
+ * the way it does.
268
+ *
269
+ * First it answered "is there nothing to serve", so an empty or half-written
270
+ * projection was indistinguishable from a human's decision and cost every
271
+ * daemon its pinned keys. The fix asked for evidence — a revocation on
272
+ * record — but asked it of the **owner**, and added
273
+ * `sitesFor(owner).length > 0` as a softener. That produced the opposite
274
+ * failure: an account with any revocation and no live consents refused every
275
+ * device it had, one paired seconds ago included.
276
+ *
277
+ * So: revocation is a fact about one device, never a mood about an owner.
278
+ * This looks up the device that signed the request and reports what the
279
+ * control plane says about *it*.
280
+ *
281
+ * The softener is gone with it. A guard whose answer changes with unrelated
282
+ * state is not a guard — enabling a site must never be the thing that
283
+ * un-revokes a machine, and under the old shape it was exactly that.
284
+ *
285
+ * A projection that knows nothing about a runner still says nothing here;
286
+ * `deviceFor` is what refuses an unknown one, with 401, which is a different
287
+ * sentence for a different situation.
288
+ *
289
+ * REVOCATION_IMMEDIATE is untouched: revoking device A still stops A on its
290
+ * next call. It stops stopping B and C.
291
+ */
292
+ revokedDevice(runnerId: string): boolean;
293
+ /** Whether this pair is consented and paused — what heartbeat reports. */
294
+ pausedFor(owner: string, siteId: string): boolean;
295
+ /**
296
+ * Every (site, owner) route this device may run — cloud_009 §3.
297
+ *
298
+ * The claim filter, collapsed to data a store can match on. `routableOwners`
299
+ * was this for one site; the hub needs it for the set, and the shape had to
300
+ * change rather than repeat, because **a set of sites and a set of owners
301
+ * multiply**. A device whose owner consented to site A, serving a roster
302
+ * member who consented to site B, appears in both sets and has no consented
303
+ * route between them. Pairs cannot express a route nobody agreed to.
304
+ *
305
+ * Both halves of the rule are here, and neither was enforced before finding
306
+ * 48's work:
307
+ *
308
+ * - **This machine's owner** must have a live consent for the site, or
309
+ * nothing of that site's runs here at all — including a roster member's
310
+ * work. The roster says whose jobs may land on this machine; consent says
311
+ * whether this machine is available to that site.
312
+ * - **Each job's owner** must have one too. That check did not exist:
313
+ * consent was enforced by the daemon plane's blanket revoked guard, which
314
+ * asks only about the claiming device's owner, so a roster member who
315
+ * never consented to a site could have their work claimed by their admin's
316
+ * machine — `CONSENT_BEFORE_ROUTE` read the other way round.
317
+ */
318
+ routesFor(deviceOwner: string): Set<string>;
319
+ /**
320
+ * Every owner whose work this device's owner may run, as a list.
321
+ *
322
+ * The same question {@link mayRunFor} answers, asked in the direction a
323
+ * *store* can use. That difference is the crux of making `claim` atomic
324
+ * (cloud_006 §3.2).
325
+ *
326
+ * Today `claim` scans every job and calls `mayRunFor` per candidate, which
327
+ * works because the projection is a local object. A shared routing store
328
+ * cannot do that: the filter has to travel to the store, and a predicate
329
+ * does not travel — you cannot send a closure to Valkey. So the projection
330
+ * is collapsed to **data** here and handed over as a set the store can
331
+ * match on.
332
+ *
333
+ * That the collapse is possible at all is a property of the design worth
334
+ * noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
335
+ * computation over the jobs. If it ever became job-dependent — "may run
336
+ * work of this size", say — an atomic claim would stop being expressible,
337
+ * and that is the moment to argue rather than to add a parameter.
338
+ *
339
+ * The owner is always included: a device runs its owner's work, and the
340
+ * relay checks that before it checks a roster.
341
+ */
342
+ ownersRunnableBy(deviceOwner: string): string[];
338
343
  /**
339
344
  * May this device's owner run work belonging to `jobOwner`?
340
345
  *
@@ -347,6 +352,190 @@ declare class Projection {
347
352
  mayRunFor(deviceOwner: string, jobOwner: string): boolean;
348
353
  }
349
354
 
355
+ /**
356
+ * Pending pairing codes — cloud_009, the cloud-pairing flow.
357
+ *
358
+ * `byollm connect` speaks the device-code flow: ask for a code, show it, poll
359
+ * while a human approves it in a browser. A relay had no way to hold that
360
+ * pending state, so cloud pairing was never implemented — the hub accepted
361
+ * only the shape where the device is *already* approved, and nothing in the
362
+ * control plane created device rows at all. Every test passed because they
363
+ * drive direct mode or seed the row with a service key: the checks proved the
364
+ * parts and never the seam.
365
+ *
366
+ * ## Why the relay holds the code, and the control plane holds the decision
367
+ *
368
+ * The code is a short-lived handle on an *assertion* — "this keypair would
369
+ * like to be a machine" — and the relay is allowed to hold assertions. The
370
+ * approval is a human looking at a fingerprint, which belongs to the control
371
+ * plane where that human is signed in.
372
+ *
373
+ * So nothing here approves anything. The daemon's poll asks whether the
374
+ * control plane's projection now contains this device as approved, and the
375
+ * answer comes from the projection rather than from a flag somebody set here.
376
+ * That is what keeps the fence intact in both directions: the hub never
377
+ * writes to the control plane, and the control plane never writes to the hub.
378
+ *
379
+ * ## What a code is worth on its own
380
+ *
381
+ * Nothing. Holding a device code lets you ask "has anyone approved this
382
+ * keypair yet", and the answer is only ever yes for a keypair whose owner
383
+ * approved it by eye. Stolen mid-flight it grants no access, which is why it
384
+ * can be a URL-safe string a person reads aloud rather than a credential.
385
+ */
386
+ /** What the relay remembers between `start` and `poll`. */
387
+ interface PendingPairing {
388
+ /** The secret the daemon polls with. Never shown to a human. */
389
+ readonly deviceCode: string;
390
+ /** The short code a person reads and types into the dashboard. */
391
+ readonly userCode: string;
392
+ /** The keys the daemon presented. What a human is about to approve. */
393
+ readonly device: PublicIdentity;
394
+ /**
395
+ * What the machine said it can run, as advertised when it asked to pair.
396
+ *
397
+ * Held so the approval screen can show a person what they are approving,
398
+ * and so presence has an answer the moment the device appears rather than
399
+ * one heartbeat later. It is a claim, like everything else in this record —
400
+ * the heartbeat is the authority and replaces it within seconds.
401
+ */
402
+ readonly capabilities: CapabilityMatrix;
403
+ /** Label the daemon offered, for the approval screen. */
404
+ readonly label: string;
405
+ readonly platform: string;
406
+ /** Epoch ms. After this the code is gone, approved or not. */
407
+ readonly expiresAt: number;
408
+ }
409
+ /**
410
+ * What happened when a code was offered for storage.
411
+ *
412
+ * `put` can refuse, and the reason it can is the whole of the rate-limit
413
+ * story on this surface: **anybody can ask to pair.** That is not a bug — a
414
+ * machine with no pairing has no credential to present — but it means a
415
+ * stranger with a script can mint pending codes in a loop, and each one
416
+ * occupies memory in a shared store for ten minutes. Without a ceiling the
417
+ * only limit is somebody's patience.
418
+ *
419
+ * So the store has a capacity and says so, and the daemon is told to try
420
+ * again shortly rather than given a code that crowds out a real one. A cap is
421
+ * a blunt instrument — under a flood, a person pairing a laptop is refused
422
+ * alongside the attacker — but a refusal that resolves in ten minutes is a
423
+ * better failure than a hub that stops routing. Per-IP limits belong at the
424
+ * edge, where the IP actually is.
425
+ */
426
+ type PutResult = "stored" | "at-capacity";
427
+ /**
428
+ * What a caller is told when pairings are being refused for load.
429
+ *
430
+ * Exported because it is said in two places by two different limits. This
431
+ * package says it when the store is at capacity; a deployment that adds a
432
+ * per-IP budget in front (the hub does — cloud_014) says it when one source
433
+ * has spent its share. **One sentence for one situation, whichever limit
434
+ * produced it**: the person reading it in a terminal is told to try again
435
+ * shortly, and which of the two bit is not a distinction they can act on.
436
+ *
437
+ * It lived inline here and the hub kept a copy, which is the one-value-two-
438
+ * names defect this codebase keeps finding — and the copy that drifts would
439
+ * drift silently, because both sentences would be plausible.
440
+ */
441
+ declare const PAIRING_BUSY_MESSAGE = "too many pairings are in progress right now \u2014 try again in a few minutes";
442
+ interface PairingCodes {
443
+ put(pending: PendingPairing): Promise<PutResult>;
444
+ /** By the secret the daemon holds. */
445
+ byDeviceCode(deviceCode: string): Promise<PendingPairing | undefined>;
446
+ /** By the short code a human typed. */
447
+ byUserCode(userCode: string): Promise<PendingPairing | undefined>;
448
+ /** After a successful pairing, so a code is single-use. */
449
+ drop(deviceCode: string): Promise<void>;
450
+ }
451
+ declare function newUserCode(): string;
452
+ /** The secret half. Long and URL-safe; never shown to anybody. */
453
+ declare const newDeviceCode: () => string;
454
+ /** How long a person has to walk to their browser and type eight characters. */
455
+ declare const PAIRING_CODE_TTL_MS: number;
456
+ /**
457
+ * How many pairings may be in flight at once, across a whole relay.
458
+ *
459
+ * Sized against reality rather than fear: a pairing takes under a minute of
460
+ * human attention, so five hundred outstanding at the same instant is a
461
+ * number this product will not reach honestly for a long time — and one an
462
+ * attacker reaches in a second. Small enough to bound the store, large enough
463
+ * that nobody legitimate meets it.
464
+ */
465
+ declare const MAX_OUTSTANDING_PAIRINGS = 500;
466
+ /**
467
+ * The in-memory implementation, for the reference relay and its tests.
468
+ *
469
+ * The hub replaces it with one backed by Valkey, because a hub is two
470
+ * replicas and a code minted on one must be pollable on the other — the same
471
+ * reason its routing store is not a `Map`.
472
+ */
473
+ declare class MemoryPairingCodes implements PairingCodes {
474
+ #private;
475
+ constructor(now?: () => number, capacity?: number);
476
+ put(pending: PendingPairing): Promise<PutResult>;
477
+ byDeviceCode(deviceCode: string): Promise<PendingPairing | undefined>;
478
+ byUserCode(userCode: string): Promise<PendingPairing | undefined>;
479
+ drop(deviceCode: string): Promise<void>;
480
+ }
481
+
482
+ /**
483
+ * What the relay asks a control plane, as one named shape.
484
+ *
485
+ * Spelled out twice — here and on `RelayOptions` — until adding `siteKey`
486
+ * made one of them wrong and the build caught it. That was luck: the two are
487
+ * structurally compared, so a field added to the *caller's* copy alone would
488
+ * have been accepted silently and the grant would have carried nothing.
489
+ */
490
+ type GrantAuthor = (input: {
491
+ readonly job: ClaimedStub;
492
+ /** The site's id in the control plane's namespace, for its policy read. */
493
+ readonly siteId: string;
494
+ /**
495
+ * The same site as the stub names it — the key id the device pinned.
496
+ *
497
+ * Carried, never derived. This is the value that gets signed, and the one a
498
+ * device can compare against `stub.site` without a lookup and without
499
+ * trusting the party that routed it.
500
+ */
501
+ readonly siteKey: string;
502
+ readonly purpose?: string;
503
+ readonly owner: string;
504
+ readonly runnerId: string;
505
+ readonly capabilities: CapabilityMatrix;
506
+ }) => Promise<GrantDecision> | GrantDecision;
507
+
508
+ declare function debugPage(state: RoutingStore, now: number,
509
+ /**
510
+ * Asked whether each device's owner still consents — cloud_008 §2.3.
511
+ *
512
+ * The page used to read a `revoked` boolean off presence. That flag was a
513
+ * stored copy of a fact the projection owns, and it is gone; the page asks
514
+ * the authority instead, which is also the only thing that stays correct
515
+ * when one daemon serves several sites.
516
+ */
517
+ routesFor?: {
518
+ siteId: string;
519
+ consents: (owner: string) => boolean;
520
+ }): Promise<string>;
521
+
522
+ /**
523
+ * The enqueue-time question, declared once.
524
+ *
525
+ * It was written out twice — here and on `RelayOptions` — and by the time
526
+ * 019 added a fourth verdict the two copies disagreed, so a relay could be
527
+ * handed an answer its own options type said was impossible. **A shape
528
+ * declared in two places is two places for it to drift.**
529
+ */
530
+ type Satisfiable = (query: {
531
+ readonly siteId: string;
532
+ readonly owner: string;
533
+ readonly purpose: string | undefined;
534
+ readonly kind: string;
535
+ }) => Promise<{
536
+ readonly verdict: "ok" | "not-declared" | "unmapped" | "waiting";
537
+ }>;
538
+
350
539
  /**
351
540
  * `@byollm/relay` — the reference relay (cloud_004 §14).
352
541
  *
@@ -372,6 +561,30 @@ declare class Projection {
372
561
  * able to read a payload is to change its types, which is a review someone
373
562
  * would have to justify rather than a line someone could slip in.
374
563
  */
564
+ /**
565
+ * What a control plane answers when a relay asks about one job.
566
+ *
567
+ * Declared here rather than imported, and deliberately narrower than what
568
+ * `@byollm/control-plane` returns: a relay needs to know whether it got a
569
+ * grant and whether a refusal is forever, and nothing else. Stating only that
570
+ * keeps the two packages independent — a relay can be wired to any control
571
+ * plane, and the reference engine satisfies this by having more, not less.
572
+ *
573
+ * `reason` is for the log. The relay never branches on it, because a relay
574
+ * that acted differently per reason would be a second implementation of a
575
+ * policy it does not own.
576
+ */
577
+ type GrantDecision = {
578
+ readonly granted: SignedGrant;
579
+ readonly declined?: undefined;
580
+ } | {
581
+ readonly granted?: undefined;
582
+ readonly declined: {
583
+ /** Never offer this job to this device again. */
584
+ readonly permanent: boolean;
585
+ readonly reason?: string;
586
+ };
587
+ };
375
588
  interface RelayOptions {
376
589
  /**
377
590
  * Which site this relay routes for.
@@ -379,20 +592,122 @@ interface RelayOptions {
379
592
  * One, in the skeleton. Multi-tenant routing is the closed piece
380
593
  * (cloud_004 §9), and it replaces this field rather than extending it.
381
594
  */
382
- readonly siteId: string;
383
- /** Consent and rosters, projected from the control plane. */
595
+ /** Consent and routing, projected from the control plane. */
384
596
  readonly fixture?: RelayFixture;
597
+ /**
598
+ * The control plane's grant-signing public key — Amendment J.
599
+ *
600
+ * Handed to daemons at pairing, and the thing every grant is checked
601
+ * against. Configuring it without {@link RelayOptions.authorGrant} is
602
+ * refused at construction: a device told to expect signed grants and then
603
+ * sent none refuses every job, and it would do so with no signal here.
604
+ */
605
+ readonly controlPlanePublic?: string | undefined;
606
+ /**
607
+ * Whether a purpose can be satisfied for this person, asked at enqueue.
608
+ *
609
+ * The relay does not hold the answer and must not: one that filtered on
610
+ * mappings would hold the mapping, which is the one thing it cannot have. So
611
+ * it asks whoever does — in practice the control plane, which already
612
+ * decides this at claim, a moment later.
613
+ *
614
+ * Optional. A relay without it refuses nothing, which is a supported
615
+ * arrangement and one an operator must be able to see they are in: say so at
616
+ * boot and on the health surface, because a check that quietly is not there
617
+ * reads as a check that passed.
618
+ */
619
+ readonly satisfiable?: Satisfiable;
620
+ /**
621
+ * Author a grant for one claimed job — Amendment J.
622
+ *
623
+ * **The relay asks; it does not decide.** Everything a grant asserts —
624
+ * whose job this is, whether they are still a member, which of the owner's
625
+ * services their mapping resolves to — is the control plane's knowledge,
626
+ * and this callback is the seam between the two. A relay wired to a
627
+ * deployment that has no control plane simply has no callback, and its
628
+ * devices serve their owners alone.
629
+ *
630
+ * Declining says whether the refusal is **permanent**, and that is the
631
+ * whole reason this returns a shape rather than `SignedGrant | undefined`.
632
+ * A relay releases a declined job, and a release can carry `refused`, which
633
+ * means never offer this job to this device again. "This person was removed
634
+ * from the team" is forever — removal stops queued claims, per hole 1.
635
+ * "Their mapping resolved to another of your machines" is emphatically not:
636
+ * marking that permanently would mean the job could never reach the device
637
+ * it was always meant for, and nothing would ever report it.
638
+ *
639
+ * The capability matrix is passed because resolution needs it — the control
640
+ * plane chooses from what this device actually advertised, never from a
641
+ * name it invented. Until byollm_016 Amendment L lands, "resolution" is the
642
+ * job's own selection or the device's default; after it, the user's
643
+ * per-purpose mapping. The seam does not change.
644
+ */
645
+ readonly authorGrant?: GrantAuthor;
385
646
  /** How long a claim is good for. */
386
647
  readonly leaseMs?: number;
387
648
  /** Injectable clock, so tests move time instead of sleeping. */
388
649
  readonly now?: () => number;
650
+ /**
651
+ * Where pending pairing codes live — cloud_009's device-code flow.
652
+ *
653
+ * Defaults to an in-memory store, which is right for the reference relay
654
+ * and wrong for a hub: two replicas mean a code minted on one must be
655
+ * pollable on the other, the same reason the routing store is not a `Map`.
656
+ */
657
+ readonly pairingCodes?: PairingCodes;
658
+ /**
659
+ * Where a human approves a code. The control plane's own URL.
660
+ *
661
+ * Given rather than derived: the relay cannot approve anything, because
662
+ * approving is looking at a fingerprint while signed in and that session
663
+ * lives in the dashboard. Absent, the device-code flow is refused as
664
+ * unsupported rather than pointed somewhere useless.
665
+ */
666
+ readonly verificationUrl?: string;
389
667
  /** Where the daemon plane is mounted. */
390
668
  readonly basePath?: string;
669
+ /**
670
+ * Serve `/debug`, which is off unless somebody asks for it.
671
+ *
672
+ * The page shows every routed job for a site, its state, who claimed it and
673
+ * how long its timers have left. It shows no prompt or result text — the
674
+ * relay does not have them — and it is genuinely useful when a route is
675
+ * behaving strangely.
676
+ *
677
+ * It is also, on anything reachable from the internet, an anonymous read of
678
+ * exactly the metadata the site plane exists to protect. That was finding
679
+ * eleven, found by curling a deployed hub. The hub refuses the route
680
+ * outright; this package used to serve it by default and leave `D005` to
681
+ * warn whoever deployed it, which is a default that fails safe only if
682
+ * somebody reads the audit.
683
+ *
684
+ * So: off, and per-site when on (cloud_009 §3 — the debug page is per-site
685
+ * or it is nothing). `D005` still fails for a relay that turned it on,
686
+ * which is the audit doing its job for an operator who made a choice rather
687
+ * than warning everybody about a default.
688
+ */
689
+ readonly debug?: boolean;
690
+ /**
691
+ * Where routing state lives — cloud_006.
692
+ *
693
+ * Defaults to an in-process {@link RelayState}, which is correct for one
694
+ * replica and is what this package ships. A hub running more than one
695
+ * replica supplies a shared implementation of {@link RoutingStore} instead;
696
+ * `packages/relay/test/two-replicas.test.ts` is why that is not optional.
697
+ *
698
+ * **The implementation is deliberately not in this package.** A Valkey
699
+ * client is a dependency every consumer would carry to get a feature only a
700
+ * multi-replica deployment uses, and the production hub is the closed piece
701
+ * (cloud_001). What ships here is the interface, the reference
702
+ * implementation, and the tests that say what an implementation must
703
+ * guarantee.
704
+ */
705
+ readonly store?: RoutingStore;
391
706
  }
392
707
  /** A running relay: one fetch handler, two planes, one debug page. */
393
708
  declare class Relay {
394
709
  #private;
395
- readonly state: RelayState;
710
+ readonly state: RoutingStore;
396
711
  readonly projection: Projection;
397
712
  constructor(options: RelayOptions);
398
713
  /** Replace the projection — a control-plane push, or a fixture edit. */
@@ -406,11 +721,11 @@ declare class Relay {
406
721
  * site vanished should return to the queue without waiting for someone to
407
722
  * ask about it.
408
723
  */
409
- sweep(): {
724
+ sweep(): Promise<{
410
725
  requeued: string[];
411
- };
726
+ }>;
412
727
  /** The whole HTTP surface. */
413
728
  handle(request: Request): Promise<Response>;
414
729
  }
415
730
 
416
- 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, SiteRecord, debugPage };
731
+ 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 };