@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/README.md +166 -4
- package/dist/chunk-OB6LPEEE.js +405 -0
- package/dist/chunk-OB6LPEEE.js.map +1 -0
- package/dist/index.d.ts +496 -153
- package/dist/index.js +1041 -271
- package/dist/index.js.map +1 -1
- package/dist/store-DPCLO12l.d.ts +655 -0
- package/dist/store-contract.d.ts +67 -0
- package/dist/store-contract.js +625 -0
- package/dist/store-contract.js.map +1 -0
- package/package.json +19 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,144 +1,8 @@
|
|
|
1
|
-
import { PublicIdentity,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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 };
|