@byollm/relay 0.1.0-alpha.5 → 0.1.0-alpha.50

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,143 +1,21 @@
1
- import { PublicIdentity, JobStub, SealedEnvelope } from '@byollm/protocol';
1
+ import { R as RoutingStore } from './store-C7jG6b9m.js';
2
+ 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-C7jG6b9m.js';
2
3
  import { z } from 'zod';
4
+ import { PublicIdentity, CapabilityMatrix } from '@byollm/protocol';
3
5
 
6
+ declare function debugPage(state: RoutingStore, now: number,
4
7
  /**
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.
8
+ * Asked whether each device's owner still consents — cloud_008 §2.3.
98
9
  *
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.
10
+ * The page used to read a `revoked` boolean off presence. That flag was a
11
+ * stored copy of a fact the projection owns, and it is gone; the page asks
12
+ * the authority instead, which is also the only thing that stays correct
13
+ * when one daemon serves several sites.
104
14
  */
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;
15
+ routesFor?: {
16
+ siteId: string;
17
+ consents: (owner: string) => boolean;
18
+ }): Promise<string>;
141
19
 
142
20
  /**
143
21
  * What the relay is told about the world — cloud_004 §14.
@@ -171,6 +49,42 @@ declare function debugPage(state: RelayState, now: number): string;
171
49
  * can open anything, and {@link RelayFixture} has no field where one could be
172
50
  * put — `RELAY_BLIND` as a type, not as a promise.
173
51
  */
52
+ /**
53
+ * A site the control plane registered and domain-verified — cloud_004 §5.
54
+ *
55
+ * **The one authority for a site's public identity.** It used to be inlined on
56
+ * every consent record, which meant a site's key had as many homes as it had
57
+ * users and nothing checked they agreed — the exact shape this project has now
58
+ * found in a version constant, a clock read, an envelope deadline, a reseal
59
+ * implementation, a package list and a docs page. Consents now reference a
60
+ * site by id and the key is looked up here.
61
+ *
62
+ * The relay needs it for two things it cannot do without:
63
+ *
64
+ * 1. **Telling a daemon who to pin** at pairing — the key that makes relayed
65
+ * work unforgeable, since the relay holds no key that could produce it.
66
+ * 2. **Authenticating the site plane.** A site calls a relay the way a daemon
67
+ * does, signing with this identity, and this is the key those signatures
68
+ * are checked against.
69
+ */
70
+ declare const SiteRecord: z.ZodObject<{
71
+ siteId: z.ZodString;
72
+ site: z.ZodObject<{
73
+ identity: z.ZodString;
74
+ encryption: z.ZodString;
75
+ encryptionSig: z.ZodString;
76
+ }, z.core.$strict>;
77
+ succeeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
78
+ identity: z.ZodObject<{
79
+ identity: z.ZodString;
80
+ encryption: z.ZodString;
81
+ encryptionSig: z.ZodString;
82
+ }, z.core.$strict>;
83
+ signature: z.ZodString;
84
+ }, z.core.$strict>>>;
85
+ retiringUntil: z.ZodOptional<z.ZodNumber>;
86
+ }, z.core.$strict>;
87
+ type SiteRecord = z.infer<typeof SiteRecord>;
174
88
  /**
175
89
  * A user's decision to let one site use their compute — cloud_004 §3.
176
90
  *
@@ -181,11 +95,7 @@ declare function debugPage(state: RelayState, now: number): string;
181
95
  declare const ConsentRecord: z.ZodObject<{
182
96
  owner: z.ZodString;
183
97
  siteId: z.ZodString;
184
- site: z.ZodObject<{
185
- identity: z.ZodString;
186
- encryption: z.ZodString;
187
- encryptionSig: z.ZodString;
188
- }, z.core.$strict>;
98
+ paused: z.ZodDefault<z.ZodBoolean>;
189
99
  }, z.core.$strict>;
190
100
  type ConsentRecord = z.infer<typeof ConsentRecord>;
191
101
  /**
@@ -202,22 +112,75 @@ declare const RosterRecord: z.ZodObject<{
202
112
  members: z.ZodArray<z.ZodString>;
203
113
  }, z.core.$strict>;
204
114
  type RosterRecord = z.infer<typeof RosterRecord>;
115
+ /**
116
+ * A device its owner has approved — cloud_005 §7.1.
117
+ *
118
+ * The relay refuses a device that is not here, and that refusal is the point.
119
+ * byollm_009's seventh finding stopped a daemon from *naming* itself; this
120
+ * stops it from *keying* itself. A device that presents keys nobody approved
121
+ * is a device whose owner never saw a fingerprint, and pairing it would make
122
+ * the relay the authority on identity — which is exactly what it must not be.
123
+ *
124
+ * The three-party shape consent already has, applied to identity: the device
125
+ * asserts, a human confirms in the control plane, the relay checks.
126
+ */
127
+ declare const DeviceRecord: z.ZodObject<{
128
+ owner: z.ZodString;
129
+ runnerId: z.ZodString;
130
+ device: z.ZodObject<{
131
+ identity: z.ZodString;
132
+ encryption: z.ZodString;
133
+ encryptionSig: z.ZodString;
134
+ }, z.core.$strict>;
135
+ }, z.core.$strict>;
136
+ type DeviceRecord = z.infer<typeof DeviceRecord>;
137
+ /** A revoked route, named by its parts. */
138
+ declare const RevocationRecord: z.ZodObject<{
139
+ owner: z.ZodString;
140
+ siteId: z.ZodString;
141
+ }, z.core.$strict>;
142
+ type RevocationRecord = z.infer<typeof RevocationRecord>;
205
143
  declare const RelayFixture: z.ZodObject<{
206
- consents: z.ZodArray<z.ZodObject<{
207
- owner: z.ZodString;
144
+ sites: z.ZodDefault<z.ZodArray<z.ZodObject<{
208
145
  siteId: z.ZodString;
209
146
  site: z.ZodObject<{
210
147
  identity: z.ZodString;
211
148
  encryption: z.ZodString;
212
149
  encryptionSig: z.ZodString;
213
150
  }, z.core.$strict>;
151
+ succeeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
152
+ identity: z.ZodObject<{
153
+ identity: z.ZodString;
154
+ encryption: z.ZodString;
155
+ encryptionSig: z.ZodString;
156
+ }, z.core.$strict>;
157
+ signature: z.ZodString;
158
+ }, z.core.$strict>>>;
159
+ retiringUntil: z.ZodOptional<z.ZodNumber>;
160
+ }, z.core.$strict>>>;
161
+ consents: z.ZodArray<z.ZodObject<{
162
+ owner: z.ZodString;
163
+ siteId: z.ZodString;
164
+ paused: z.ZodDefault<z.ZodBoolean>;
214
165
  }, z.core.$strict>>;
166
+ devices: z.ZodDefault<z.ZodArray<z.ZodObject<{
167
+ owner: z.ZodString;
168
+ runnerId: z.ZodString;
169
+ device: z.ZodObject<{
170
+ identity: z.ZodString;
171
+ encryption: z.ZodString;
172
+ encryptionSig: z.ZodString;
173
+ }, z.core.$strict>;
174
+ }, z.core.$strict>>>;
215
175
  rosters: z.ZodDefault<z.ZodArray<z.ZodObject<{
216
176
  id: z.ZodString;
217
177
  owner: z.ZodString;
218
178
  members: z.ZodArray<z.ZodString>;
219
179
  }, z.core.$strict>>>;
220
- revoked: z.ZodDefault<z.ZodArray<z.ZodString>>;
180
+ revoked: z.ZodDefault<z.ZodArray<z.ZodObject<{
181
+ owner: z.ZodString;
182
+ siteId: z.ZodString;
183
+ }, z.core.$strict>>>;
221
184
  }, z.core.$strict>;
222
185
  type RelayFixture = z.infer<typeof RelayFixture>;
223
186
  /** An empty projection: nothing consented, so nothing routes. */
@@ -235,8 +198,138 @@ declare class Projection {
235
198
  constructor(fixture?: RelayFixture);
236
199
  /** Replace the projection wholesale — the control plane pushed a new one. */
237
200
  replace(fixture: RelayFixture): void;
238
- /** The consent binding this owner to this site, if it exists and stands. */
201
+ /**
202
+ * The site this id names, if the control plane registered it.
203
+ *
204
+ * The only source of a site's public identity in this package. Everything
205
+ * that pins, verifies or seals to a site starts here.
206
+ */
207
+ siteFor(siteId: string): SiteRecord | null;
208
+ /**
209
+ * The device this runner id names, if a human approved it.
210
+ *
211
+ * Returns null for a device the control plane does not know, which is how
212
+ * the relay refuses to be the authority on identity.
213
+ */
214
+ deviceFor(runnerId: string): DeviceRecord | null;
215
+ /** The device approved for these exact keys, if any. */
216
+ deviceByFingerprint(identityPublic: string): DeviceRecord | null;
217
+ /**
218
+ * The consent binding this owner to this site, if it exists and stands.
219
+ *
220
+ * **Liveness, not routing.** A paused consent is returned here: the
221
+ * relationship exists, the daemon is not revoked, the pairing stands. Ask
222
+ * {@link Projection.mayRouteFor} before moving anybody's work — the two
223
+ * questions have different answers and one method answering both is how a
224
+ * paused user would quietly start routing again.
225
+ */
239
226
  consentFor(owner: string, siteId: string): ConsentRecord | null;
227
+ /**
228
+ * Every site this owner may route with — cloud_009 §3.
229
+ *
230
+ * The set a pairing covers, and the set a claim will filter on. Consent
231
+ * decides it, which is the sentence the whole design rests on: a site
232
+ * appears here because a human clicked, never because a site asked to be
233
+ * here and never because a daemon named it.
234
+ *
235
+ * **Paused sites are here, and that is deliberate** — cloud_008 finding 48
236
+ * as ratified. A paused consent routes nothing and keeps its pin: the
237
+ * relationship stands, the key the daemon compared a fingerprint of stays
238
+ * pinned, and re-consenting never costs a re-pair. Written the other way
239
+ * round first, and three of the paused tests failed by refusing to pair at
240
+ * all — which is the trap the finding is about, arriving through the door
241
+ * marked "be stricter".
242
+ *
243
+ * So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
244
+ * questions with different answers, kept apart for the same reason
245
+ * `consentFor` and `mayRouteFor` are: one method answering both is how a
246
+ * paused user quietly starts routing again, or quietly loses their machine.
247
+ *
248
+ * Sorted by site id so two calls with the same projection produce the same
249
+ * answer: this ends up in a pairings file and in a fingerprint list a human
250
+ * compares by eye, and an order that drifts between polls is a diff nobody
251
+ * can read.
252
+ */
253
+ sitesFor(owner: string): SiteRecord[];
254
+ /**
255
+ * May this owner's work move for this site, right now?
256
+ *
257
+ * Consent exists, was not revoked, and is not paused. The routing question,
258
+ * kept apart from {@link Projection.consentFor}'s liveness one so that a
259
+ * caller has to pick which it means.
260
+ */
261
+ mayRouteFor(owner: string, siteId: string): boolean;
262
+ /**
263
+ * Has this owner's relationship *ended* — V1-2?
264
+ *
265
+ * Not "is there nothing to serve". Those were one question until the pre-v1
266
+ * review pulled them apart, and the difference is a machine's pinned keys:
267
+ * an empty answer made the daemon stop, cancel everything and **delete its
268
+ * pairings file**, so a projection that arrived empty or half-written — one
269
+ * bad control-plane push — cost every daemon its pins and every user a
270
+ * re-pair they never asked for.
271
+ *
272
+ * Revocation is a thing somebody did, and this asks for the evidence of it:
273
+ * a revocation record for this owner, and nothing left standing. A
274
+ * projection that simply knows nothing says nothing — the relay answers
275
+ * normally, the daemon serves nobody, and the pairing survives to be
276
+ * correct again when the next push lands.
277
+ *
278
+ * The `revoked` list exists precisely for this and was consulted by
279
+ * nothing. Its own doc said why: "the row is gone" and "the row was
280
+ * revoked" are different answers, and only one of them is a decision.
281
+ */
282
+ revokedOutright(owner: string): boolean;
283
+ /** Whether this pair is consented and paused — what heartbeat reports. */
284
+ pausedFor(owner: string, siteId: string): boolean;
285
+ /**
286
+ * Every (site, owner) route this device may run — cloud_009 §3.
287
+ *
288
+ * The claim filter, collapsed to data a store can match on. `routableOwners`
289
+ * was this for one site; the hub needs it for the set, and the shape had to
290
+ * change rather than repeat, because **a set of sites and a set of owners
291
+ * multiply**. A device whose owner consented to site A, serving a roster
292
+ * member who consented to site B, appears in both sets and has no consented
293
+ * route between them. Pairs cannot express a route nobody agreed to.
294
+ *
295
+ * Both halves of the rule are here, and neither was enforced before finding
296
+ * 48's work:
297
+ *
298
+ * - **This machine's owner** must have a live consent for the site, or
299
+ * nothing of that site's runs here at all — including a roster member's
300
+ * work. The roster says whose jobs may land on this machine; consent says
301
+ * whether this machine is available to that site.
302
+ * - **Each job's owner** must have one too. That check did not exist:
303
+ * consent was enforced by the daemon plane's blanket revoked guard, which
304
+ * asks only about the claiming device's owner, so a roster member who
305
+ * never consented to a site could have their work claimed by their admin's
306
+ * machine — `CONSENT_BEFORE_ROUTE` read the other way round.
307
+ */
308
+ routesFor(deviceOwner: string): Set<string>;
309
+ /**
310
+ * Every owner whose work this device's owner may run, as a list.
311
+ *
312
+ * The same question {@link mayRunFor} answers, asked in the direction a
313
+ * *store* can use. That difference is the crux of making `claim` atomic
314
+ * (cloud_006 §3.2).
315
+ *
316
+ * Today `claim` scans every job and calls `mayRunFor` per candidate, which
317
+ * works because the projection is a local object. A shared routing store
318
+ * cannot do that: the filter has to travel to the store, and a predicate
319
+ * does not travel — you cannot send a closure to Valkey. So the projection
320
+ * is collapsed to **data** here and handed over as a set the store can
321
+ * match on.
322
+ *
323
+ * That the collapse is possible at all is a property of the design worth
324
+ * noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
325
+ * computation over the jobs. If it ever became job-dependent — "may run
326
+ * work of this size", say — an atomic claim would stop being expressible,
327
+ * and that is the moment to argue rather than to add a parameter.
328
+ *
329
+ * The owner is always included: a device runs its owner's work, and the
330
+ * relay checks that before it checks a roster.
331
+ */
332
+ ownersRunnableBy(deviceOwner: string): string[];
240
333
  /**
241
334
  * May this device's owner run work belonging to `jobOwner`?
242
335
  *
@@ -249,6 +342,133 @@ declare class Projection {
249
342
  mayRunFor(deviceOwner: string, jobOwner: string): boolean;
250
343
  }
251
344
 
345
+ /**
346
+ * Pending pairing codes — cloud_009, the cloud-pairing flow.
347
+ *
348
+ * `byollm connect` speaks the device-code flow: ask for a code, show it, poll
349
+ * while a human approves it in a browser. A relay had no way to hold that
350
+ * pending state, so cloud pairing was never implemented — the hub accepted
351
+ * only the shape where the device is *already* approved, and nothing in the
352
+ * control plane created device rows at all. Every test passed because they
353
+ * drive direct mode or seed the row with a service key: the checks proved the
354
+ * parts and never the seam.
355
+ *
356
+ * ## Why the relay holds the code, and the control plane holds the decision
357
+ *
358
+ * The code is a short-lived handle on an *assertion* — "this keypair would
359
+ * like to be a machine" — and the relay is allowed to hold assertions. The
360
+ * approval is a human looking at a fingerprint, which belongs to the control
361
+ * plane where that human is signed in.
362
+ *
363
+ * So nothing here approves anything. The daemon's poll asks whether the
364
+ * control plane's projection now contains this device as approved, and the
365
+ * answer comes from the projection rather than from a flag somebody set here.
366
+ * That is what keeps the fence intact in both directions: the hub never
367
+ * writes to the control plane, and the control plane never writes to the hub.
368
+ *
369
+ * ## What a code is worth on its own
370
+ *
371
+ * Nothing. Holding a device code lets you ask "has anyone approved this
372
+ * keypair yet", and the answer is only ever yes for a keypair whose owner
373
+ * approved it by eye. Stolen mid-flight it grants no access, which is why it
374
+ * can be a URL-safe string a person reads aloud rather than a credential.
375
+ */
376
+ /** What the relay remembers between `start` and `poll`. */
377
+ interface PendingPairing {
378
+ /** The secret the daemon polls with. Never shown to a human. */
379
+ readonly deviceCode: string;
380
+ /** The short code a person reads and types into the dashboard. */
381
+ readonly userCode: string;
382
+ /** The keys the daemon presented. What a human is about to approve. */
383
+ readonly device: PublicIdentity;
384
+ /**
385
+ * What the machine said it can run, as advertised when it asked to pair.
386
+ *
387
+ * Held so the approval screen can show a person what they are approving,
388
+ * and so presence has an answer the moment the device appears rather than
389
+ * one heartbeat later. It is a claim, like everything else in this record —
390
+ * the heartbeat is the authority and replaces it within seconds.
391
+ */
392
+ readonly capabilities: CapabilityMatrix;
393
+ /** Label the daemon offered, for the approval screen. */
394
+ readonly label: string;
395
+ readonly platform: string;
396
+ /** Epoch ms. After this the code is gone, approved or not. */
397
+ readonly expiresAt: number;
398
+ }
399
+ /**
400
+ * What happened when a code was offered for storage.
401
+ *
402
+ * `put` can refuse, and the reason it can is the whole of the rate-limit
403
+ * story on this surface: **anybody can ask to pair.** That is not a bug — a
404
+ * machine with no pairing has no credential to present — but it means a
405
+ * stranger with a script can mint pending codes in a loop, and each one
406
+ * occupies memory in a shared store for ten minutes. Without a ceiling the
407
+ * only limit is somebody's patience.
408
+ *
409
+ * So the store has a capacity and says so, and the daemon is told to try
410
+ * again shortly rather than given a code that crowds out a real one. A cap is
411
+ * a blunt instrument — under a flood, a person pairing a laptop is refused
412
+ * alongside the attacker — but a refusal that resolves in ten minutes is a
413
+ * better failure than a hub that stops routing. Per-IP limits belong at the
414
+ * edge, where the IP actually is.
415
+ */
416
+ type PutResult = "stored" | "at-capacity";
417
+ /**
418
+ * What a caller is told when pairings are being refused for load.
419
+ *
420
+ * Exported because it is said in two places by two different limits. This
421
+ * package says it when the store is at capacity; a deployment that adds a
422
+ * per-IP budget in front (the hub does — cloud_014) says it when one source
423
+ * has spent its share. **One sentence for one situation, whichever limit
424
+ * produced it**: the person reading it in a terminal is told to try again
425
+ * shortly, and which of the two bit is not a distinction they can act on.
426
+ *
427
+ * It lived inline here and the hub kept a copy, which is the one-value-two-
428
+ * names defect this codebase keeps finding — and the copy that drifts would
429
+ * drift silently, because both sentences would be plausible.
430
+ */
431
+ declare const PAIRING_BUSY_MESSAGE = "too many pairings are in progress right now \u2014 try again in a few minutes";
432
+ interface PairingCodes {
433
+ put(pending: PendingPairing): Promise<PutResult>;
434
+ /** By the secret the daemon holds. */
435
+ byDeviceCode(deviceCode: string): Promise<PendingPairing | undefined>;
436
+ /** By the short code a human typed. */
437
+ byUserCode(userCode: string): Promise<PendingPairing | undefined>;
438
+ /** After a successful pairing, so a code is single-use. */
439
+ drop(deviceCode: string): Promise<void>;
440
+ }
441
+ declare function newUserCode(): string;
442
+ /** The secret half. Long and URL-safe; never shown to anybody. */
443
+ declare const newDeviceCode: () => string;
444
+ /** How long a person has to walk to their browser and type eight characters. */
445
+ declare const PAIRING_CODE_TTL_MS: number;
446
+ /**
447
+ * How many pairings may be in flight at once, across a whole relay.
448
+ *
449
+ * Sized against reality rather than fear: a pairing takes under a minute of
450
+ * human attention, so five hundred outstanding at the same instant is a
451
+ * number this product will not reach honestly for a long time — and one an
452
+ * attacker reaches in a second. Small enough to bound the store, large enough
453
+ * that nobody legitimate meets it.
454
+ */
455
+ declare const MAX_OUTSTANDING_PAIRINGS = 500;
456
+ /**
457
+ * The in-memory implementation, for the reference relay and its tests.
458
+ *
459
+ * The hub replaces it with one backed by Valkey, because a hub is two
460
+ * replicas and a code minted on one must be pollable on the other — the same
461
+ * reason its routing store is not a `Map`.
462
+ */
463
+ declare class MemoryPairingCodes implements PairingCodes {
464
+ #private;
465
+ constructor(now?: () => number, capacity?: number);
466
+ put(pending: PendingPairing): Promise<PutResult>;
467
+ byDeviceCode(deviceCode: string): Promise<PendingPairing | undefined>;
468
+ byUserCode(userCode: string): Promise<PendingPairing | undefined>;
469
+ drop(deviceCode: string): Promise<void>;
470
+ }
471
+
252
472
  /**
253
473
  * `@byollm/relay` — the reference relay (cloud_004 §14).
254
474
  *
@@ -281,20 +501,73 @@ interface RelayOptions {
281
501
  * One, in the skeleton. Multi-tenant routing is the closed piece
282
502
  * (cloud_004 §9), and it replaces this field rather than extending it.
283
503
  */
284
- readonly siteId: string;
285
504
  /** Consent and rosters, projected from the control plane. */
286
505
  readonly fixture?: RelayFixture;
287
506
  /** How long a claim is good for. */
288
507
  readonly leaseMs?: number;
289
508
  /** Injectable clock, so tests move time instead of sleeping. */
290
509
  readonly now?: () => number;
510
+ /**
511
+ * Where pending pairing codes live — cloud_009's device-code flow.
512
+ *
513
+ * Defaults to an in-memory store, which is right for the reference relay
514
+ * and wrong for a hub: two replicas mean a code minted on one must be
515
+ * pollable on the other, the same reason the routing store is not a `Map`.
516
+ */
517
+ readonly pairingCodes?: PairingCodes;
518
+ /**
519
+ * Where a human approves a code. The control plane's own URL.
520
+ *
521
+ * Given rather than derived: the relay cannot approve anything, because
522
+ * approving is looking at a fingerprint while signed in and that session
523
+ * lives in the dashboard. Absent, the device-code flow is refused as
524
+ * unsupported rather than pointed somewhere useless.
525
+ */
526
+ readonly verificationUrl?: string;
291
527
  /** Where the daemon plane is mounted. */
292
528
  readonly basePath?: string;
529
+ /**
530
+ * Serve `/debug`, which is off unless somebody asks for it.
531
+ *
532
+ * The page shows every routed job for a site, its state, who claimed it and
533
+ * how long its timers have left. It shows no prompt or result text — the
534
+ * relay does not have them — and it is genuinely useful when a route is
535
+ * behaving strangely.
536
+ *
537
+ * It is also, on anything reachable from the internet, an anonymous read of
538
+ * exactly the metadata the site plane exists to protect. That was finding
539
+ * eleven, found by curling a deployed hub. The hub refuses the route
540
+ * outright; this package used to serve it by default and leave `D005` to
541
+ * warn whoever deployed it, which is a default that fails safe only if
542
+ * somebody reads the audit.
543
+ *
544
+ * So: off, and per-site when on (cloud_009 §3 — the debug page is per-site
545
+ * or it is nothing). `D005` still fails for a relay that turned it on,
546
+ * which is the audit doing its job for an operator who made a choice rather
547
+ * than warning everybody about a default.
548
+ */
549
+ readonly debug?: boolean;
550
+ /**
551
+ * Where routing state lives — cloud_006.
552
+ *
553
+ * Defaults to an in-process {@link RelayState}, which is correct for one
554
+ * replica and is what this package ships. A hub running more than one
555
+ * replica supplies a shared implementation of {@link RoutingStore} instead;
556
+ * `packages/relay/test/two-replicas.test.ts` is why that is not optional.
557
+ *
558
+ * **The implementation is deliberately not in this package.** A Valkey
559
+ * client is a dependency every consumer would carry to get a feature only a
560
+ * multi-replica deployment uses, and the production hub is the closed piece
561
+ * (cloud_001). What ships here is the interface, the reference
562
+ * implementation, and the tests that say what an implementation must
563
+ * guarantee.
564
+ */
565
+ readonly store?: RoutingStore;
293
566
  }
294
567
  /** A running relay: one fetch handler, two planes, one debug page. */
295
568
  declare class Relay {
296
569
  #private;
297
- readonly state: RelayState;
570
+ readonly state: RoutingStore;
298
571
  readonly projection: Projection;
299
572
  constructor(options: RelayOptions);
300
573
  /** Replace the projection — a control-plane push, or a fixture edit. */
@@ -308,11 +581,11 @@ declare class Relay {
308
581
  * site vanished should return to the queue without waiting for someone to
309
582
  * ask about it.
310
583
  */
311
- sweep(): {
584
+ sweep(): Promise<{
312
585
  requeued: string[];
313
- };
586
+ }>;
314
587
  /** The whole HTTP surface. */
315
588
  handle(request: Request): Promise<Response>;
316
589
  }
317
590
 
318
- export { AWAITING_PAYLOAD_MS, ConsentRecord, EMPTY_FIXTURE, type Presence, Projection, Relay, RelayFixture, RelayFixture as RelayFixtureSchema, type RelayOptions, RelayState, RosterRecord, type RoutedJob, type RoutedState, debugPage };
591
+ export { ConsentRecord, DeviceRecord, EMPTY_FIXTURE, 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 };