@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/README.md +166 -4
- package/dist/chunk-XLWNHHLS.js +399 -0
- package/dist/chunk-XLWNHHLS.js.map +1 -0
- package/dist/index.d.ts +422 -149
- package/dist/index.js +959 -279
- package/dist/index.js.map +1 -1
- package/dist/store-C7jG6b9m.d.ts +627 -0
- package/dist/store-contract.d.ts +67 -0
- package/dist/store-contract.js +641 -0
- package/dist/store-contract.js.map +1 -0
- package/package.json +27 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,143 +1,21 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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:
|
|
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 {
|
|
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 };
|