@byollm/relay 0.1.0-alpha.9 → 0.1.0-alpha.91
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 +162 -5
- package/dist/chunk-4ISUXCIO.js +484 -0
- package/dist/chunk-4ISUXCIO.js.map +1 -0
- package/dist/index.d.ts +506 -163
- package/dist/index.js +980 -264
- package/dist/index.js.map +1 -1
- package/dist/store-BNhCedHZ.d.ts +771 -0
- package/dist/store-contract.d.ts +77 -0
- package/dist/store-contract.js +712 -0
- package/dist/store-contract.js.map +1 -0
- package/package.json +19 -4
package/dist/index.js
CHANGED
|
@@ -1,128 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AWAITING_PAYLOAD_MS,
|
|
3
|
+
CHURN_EXPIRIES,
|
|
4
|
+
CHURN_QUIET_MS,
|
|
5
|
+
CHURN_WINDOW_MS,
|
|
6
|
+
RETRY_AFTER_MS,
|
|
7
|
+
RelayState,
|
|
8
|
+
SEAL_ATTEMPTS_BEFORE_EVICTION,
|
|
9
|
+
UNSEALED_PER_PAIR,
|
|
10
|
+
routeKey
|
|
11
|
+
} from "./chunk-4ISUXCIO.js";
|
|
12
|
+
|
|
13
|
+
// src/index.ts
|
|
14
|
+
import {
|
|
15
|
+
checkProtocolVersion,
|
|
16
|
+
declaredVersion
|
|
17
|
+
} from "@byollm/protocol";
|
|
18
|
+
|
|
1
19
|
// src/daemon-plane.ts
|
|
2
20
|
import {
|
|
21
|
+
PairPollRequest,
|
|
22
|
+
PairStartRequest,
|
|
3
23
|
ClaimRequest,
|
|
4
24
|
FetchRequest,
|
|
5
25
|
HeartbeatRequest,
|
|
26
|
+
MAX_ENVELOPE_BYTES as MAX_ENVELOPE_BYTES2,
|
|
6
27
|
PROTOCOL_VERSION,
|
|
7
28
|
ReleaseRequest,
|
|
8
29
|
ResultRequest,
|
|
9
30
|
RequestSignature,
|
|
31
|
+
envelopeBytes,
|
|
10
32
|
keyId,
|
|
11
33
|
verifyRequest,
|
|
12
34
|
verifyPublicIdentity,
|
|
13
|
-
|
|
35
|
+
updateOfferFor,
|
|
36
|
+
checkDaemonFloor,
|
|
37
|
+
UPGRADE_COMMAND,
|
|
38
|
+
PublicIdentity,
|
|
39
|
+
ERROR_STATUS as ERROR_STATUS2
|
|
14
40
|
} from "@byollm/protocol";
|
|
15
|
-
import { randomUUID } from "crypto";
|
|
16
41
|
import { z } from "zod";
|
|
17
42
|
|
|
18
|
-
// src/
|
|
19
|
-
|
|
20
|
-
var
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* disturb work in flight.
|
|
40
|
-
*/
|
|
41
|
-
enqueue(input) {
|
|
42
|
-
const existing = this.#jobs.get(input.id);
|
|
43
|
-
if (existing) return existing;
|
|
44
|
-
const job = {
|
|
45
|
-
id: input.id,
|
|
46
|
-
siteId: input.siteId,
|
|
47
|
-
stub: input.stub,
|
|
48
|
-
state: "queued"
|
|
49
|
-
};
|
|
50
|
-
this.#jobs.set(job.id, job);
|
|
51
|
-
return job;
|
|
52
|
-
}
|
|
53
|
-
job(jobId) {
|
|
54
|
-
return this.#jobs.get(jobId);
|
|
55
|
-
}
|
|
56
|
-
jobs() {
|
|
57
|
-
return [...this.#jobs.values()];
|
|
58
|
-
}
|
|
59
|
-
/** Jobs a site must seal for, right now. */
|
|
60
|
-
awaiting(siteId) {
|
|
61
|
-
return this.jobs().filter(
|
|
62
|
-
(j) => j.siteId === siteId && j.state === "awaiting-payload"
|
|
63
|
-
);
|
|
43
|
+
// src/pairing-codes.ts
|
|
44
|
+
import { randomBytes } from "crypto";
|
|
45
|
+
var PAIRING_BUSY_MESSAGE = "too many pairings are in progress right now \u2014 try again in a few minutes";
|
|
46
|
+
var HUMAN_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
47
|
+
function newUserCode() {
|
|
48
|
+
const groups = Array.from(
|
|
49
|
+
randomBytes(8),
|
|
50
|
+
(byte) => HUMAN_ALPHABET.charAt(byte % HUMAN_ALPHABET.length)
|
|
51
|
+
);
|
|
52
|
+
return `${groups.slice(0, 4).join("")}-${groups.slice(4).join("")}`;
|
|
53
|
+
}
|
|
54
|
+
var newDeviceCode = () => randomBytes(32).toString("base64url");
|
|
55
|
+
var PAIRING_CODE_TTL_MS = 10 * 60 * 1e3;
|
|
56
|
+
var MAX_OUTSTANDING_PAIRINGS = 500;
|
|
57
|
+
var MemoryPairingCodes = class {
|
|
58
|
+
#byDevice = /* @__PURE__ */ new Map();
|
|
59
|
+
#now;
|
|
60
|
+
#capacity;
|
|
61
|
+
constructor(now = Date.now, capacity = MAX_OUTSTANDING_PAIRINGS) {
|
|
62
|
+
this.#now = now;
|
|
63
|
+
this.#capacity = capacity;
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return this
|
|
68
|
-
(j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
|
|
69
|
-
);
|
|
65
|
+
#live(pending) {
|
|
66
|
+
if (!pending) return void 0;
|
|
67
|
+
return pending.expiresAt > this.#now() ? pending : void 0;
|
|
70
68
|
}
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return existing;
|
|
69
|
+
put(pending) {
|
|
70
|
+
const now = this.#now();
|
|
71
|
+
for (const [code, held] of this.#byDevice) {
|
|
72
|
+
if (held.expiresAt <= now) this.#byDevice.delete(code);
|
|
76
73
|
}
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
const fingerprint = pending.device.identity;
|
|
75
|
+
for (const [code, held] of this.#byDevice) {
|
|
76
|
+
if (held.device.identity === fingerprint) this.#byDevice.delete(code);
|
|
77
|
+
}
|
|
78
|
+
if (this.#byDevice.size >= this.#capacity)
|
|
79
|
+
return Promise.resolve("at-capacity");
|
|
80
|
+
this.#byDevice.set(pending.deviceCode, pending);
|
|
81
|
+
return Promise.resolve("stored");
|
|
83
82
|
}
|
|
84
|
-
|
|
85
|
-
return
|
|
83
|
+
byDeviceCode(deviceCode) {
|
|
84
|
+
return Promise.resolve(this.#live(this.#byDevice.get(deviceCode)));
|
|
86
85
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
* a device did nothing for ten seconds and another one gets a turn.
|
|
93
|
-
*/
|
|
94
|
-
requeue(job) {
|
|
95
|
-
job.state = "queued";
|
|
96
|
-
delete job.claimedBy;
|
|
97
|
-
delete job.awaitingUntil;
|
|
98
|
-
delete job.payload;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Fire whatever the clock says is due, and report it.
|
|
102
|
-
*
|
|
103
|
-
* Returns the jobs it requeued so a caller can log or surface them — a
|
|
104
|
-
* timeout that fires invisibly is indistinguishable from a job that was
|
|
105
|
-
* never claimed, and those want very different debugging.
|
|
106
|
-
*/
|
|
107
|
-
sweep(now) {
|
|
108
|
-
const requeued = [];
|
|
109
|
-
for (const job of this.#jobs.values()) {
|
|
110
|
-
if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
|
|
111
|
-
this.requeue(job);
|
|
112
|
-
requeued.push(job);
|
|
113
|
-
}
|
|
114
|
-
const lease = job.claimedBy;
|
|
115
|
-
if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
|
|
116
|
-
this.requeue(job);
|
|
117
|
-
requeued.push(job);
|
|
118
|
-
}
|
|
86
|
+
byUserCode(userCode) {
|
|
87
|
+
const wanted = userCode.trim().toUpperCase();
|
|
88
|
+
for (const pending of this.#byDevice.values()) {
|
|
89
|
+
if (pending.userCode === wanted)
|
|
90
|
+
return Promise.resolve(this.#live(pending));
|
|
119
91
|
}
|
|
120
|
-
return
|
|
92
|
+
return Promise.resolve(void 0);
|
|
93
|
+
}
|
|
94
|
+
drop(deviceCode) {
|
|
95
|
+
this.#byDevice.delete(deviceCode);
|
|
96
|
+
return Promise.resolve();
|
|
121
97
|
}
|
|
122
98
|
};
|
|
123
99
|
|
|
100
|
+
// src/refusals.ts
|
|
101
|
+
import {
|
|
102
|
+
tooLargeMessage,
|
|
103
|
+
ERROR_STATUS,
|
|
104
|
+
MAX_CLOCK_SKEW_MS,
|
|
105
|
+
MAX_ENVELOPE_BYTES
|
|
106
|
+
} from "@byollm/protocol";
|
|
107
|
+
function clockSkewRefusal(now) {
|
|
108
|
+
return {
|
|
109
|
+
status: ERROR_STATUS["clock-skew"],
|
|
110
|
+
body: {
|
|
111
|
+
error: "clock-skew",
|
|
112
|
+
message: "this request's timestamp is too far from the server's clock; check the machine's time and try again",
|
|
113
|
+
// So the far side can say *how far off* rather than *that something is
|
|
114
|
+
// wrong*. Not a disclosure: the heartbeat response returns the same
|
|
115
|
+
// value, and so does every `Date` header.
|
|
116
|
+
serverTime: now,
|
|
117
|
+
maxSkewMs: MAX_CLOCK_SKEW_MS
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function tooLargeRefusal(bytes) {
|
|
122
|
+
return {
|
|
123
|
+
status: ERROR_STATUS["bad-request"],
|
|
124
|
+
body: {
|
|
125
|
+
error: "bad-request",
|
|
126
|
+
message: `${tooLargeMessage({ bytes, limit: MAX_ENVELOPE_BYTES })} Every plan has the same ceiling.`
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
124
131
|
// src/daemon-plane.ts
|
|
125
132
|
var ok = (body) => ({ status: 200, body });
|
|
133
|
+
var REFUSALS = {
|
|
134
|
+
"not-found": {
|
|
135
|
+
status: 404,
|
|
136
|
+
body: { error: "not-found", message: "unknown job" }
|
|
137
|
+
},
|
|
138
|
+
// `forbidden`, not `unauthorized` — V1-13. Both of these are an
|
|
139
|
+
// *identified* caller being refused, which is what 403 means and what the
|
|
140
|
+
// table says `forbidden` is for; `unauthorized` is 401 and means "we do not
|
|
141
|
+
// know who you are". Served as 403 with a 401's code, a revoked daemon and
|
|
142
|
+
// an unsigned one looked alike in every log and every client branch, and
|
|
143
|
+
// "check your keys" is the wrong advice for both in opposite directions.
|
|
144
|
+
"not-holder": {
|
|
145
|
+
status: 403,
|
|
146
|
+
body: {
|
|
147
|
+
error: "forbidden",
|
|
148
|
+
message: "this runner does not hold the job"
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"stale-lease": {
|
|
152
|
+
status: 403,
|
|
153
|
+
body: { error: "forbidden", message: "that lease is no longer current" }
|
|
154
|
+
},
|
|
155
|
+
"not-ready": {
|
|
156
|
+
status: 409,
|
|
157
|
+
body: {
|
|
158
|
+
error: "not-ready",
|
|
159
|
+
message: "the site has not sealed this job yet"
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
// Also a 409, and deliberately a different code: `not-ready` means keep
|
|
163
|
+
// asking and this means stop. A daemon that read them as one would poll a
|
|
164
|
+
// finished job until its lease ran out.
|
|
165
|
+
terminal: {
|
|
166
|
+
status: 409,
|
|
167
|
+
body: { error: "too-late", message: "this job has already finished" }
|
|
168
|
+
}
|
|
169
|
+
};
|
|
126
170
|
var fail = (status, error, message) => ({
|
|
127
171
|
status,
|
|
128
172
|
body: { error, message }
|
|
@@ -142,7 +186,11 @@ var DaemonPlane = class {
|
|
|
142
186
|
* relay that substituted its own identity here could inject work — and would
|
|
143
187
|
* need a private key to do it, which is why it has none.
|
|
144
188
|
*/
|
|
145
|
-
pair(body) {
|
|
189
|
+
async pair(body) {
|
|
190
|
+
const start = PairStartRequest.safeParse(body);
|
|
191
|
+
if (start.success) return this.#pairStart(start.data);
|
|
192
|
+
const poll = PairPollRequest.safeParse(body);
|
|
193
|
+
if (poll.success) return this.#pairPoll(poll.data);
|
|
146
194
|
const parsed = PairFixtureRequest.safeParse(body);
|
|
147
195
|
if (!parsed.success) {
|
|
148
196
|
return fail(400, "bad-request", "pair request failed schema validation");
|
|
@@ -150,16 +198,9 @@ var DaemonPlane = class {
|
|
|
150
198
|
if (!verifyPublicIdentity(parsed.data.device)) {
|
|
151
199
|
return fail(400, "bad-request", "the device identity is not consistent");
|
|
152
200
|
}
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
this
|
|
156
|
-
);
|
|
157
|
-
if (!consent) {
|
|
158
|
-
return fail(403, "unauthorized", "no consent record for this user");
|
|
159
|
-
}
|
|
160
|
-
const site = this.#deps.projection.siteFor(this.#deps.siteId);
|
|
161
|
-
if (!site) {
|
|
162
|
-
return fail(403, "unauthorized", "this site is not registered");
|
|
201
|
+
const sites = this.#deps.projection.sitesFor(parsed.data.owner);
|
|
202
|
+
if (sites.length === 0) {
|
|
203
|
+
return fail(403, "forbidden", "no consent record for this user");
|
|
163
204
|
}
|
|
164
205
|
const approved = this.#deps.projection.deviceByFingerprint(
|
|
165
206
|
parsed.data.device.identity
|
|
@@ -167,36 +208,167 @@ var DaemonPlane = class {
|
|
|
167
208
|
if (!approved) {
|
|
168
209
|
return fail(
|
|
169
210
|
403,
|
|
170
|
-
"
|
|
211
|
+
"forbidden",
|
|
171
212
|
"this device has not been approved by its owner"
|
|
172
213
|
);
|
|
173
214
|
}
|
|
174
215
|
if (approved.owner !== parsed.data.owner) {
|
|
175
|
-
return fail(403, "
|
|
216
|
+
return fail(403, "forbidden", "this device belongs to another owner");
|
|
176
217
|
}
|
|
177
218
|
const runnerId = approved.runnerId;
|
|
178
|
-
this.#deps.state.seen({
|
|
219
|
+
await this.#deps.state.seen({
|
|
179
220
|
runnerId,
|
|
180
221
|
owner: parsed.data.owner,
|
|
181
222
|
device: parsed.data.device,
|
|
182
|
-
|
|
223
|
+
// The fixture exchange carries no matrix — it models a consent that
|
|
224
|
+
// arrived from a file, not a daemon describing itself. Empty until the
|
|
225
|
+
// first heartbeat, which is seconds away and is the authority anyway.
|
|
226
|
+
capabilities: [],
|
|
227
|
+
withheld: []
|
|
183
228
|
});
|
|
184
229
|
return ok({
|
|
185
230
|
protocolVersion: PROTOCOL_VERSION,
|
|
186
231
|
runnerId,
|
|
187
|
-
/**
|
|
188
|
-
|
|
232
|
+
/**
|
|
233
|
+
* The *sites'* keys. See the note above — this is load-bearing.
|
|
234
|
+
*
|
|
235
|
+
* The set this owner has consented to, keyed by each site's identity
|
|
236
|
+
* key id (cloud_009 §5). Paused sites are here: a paused consent keeps
|
|
237
|
+
* its pin so re-consenting never costs a re-pair, and what it does not
|
|
238
|
+
* do is route.
|
|
239
|
+
*/
|
|
240
|
+
sites: Object.fromEntries(
|
|
241
|
+
sites.map((record) => [keyId(record.site.identity), record.site])
|
|
242
|
+
),
|
|
243
|
+
// The key every later grant is checked against — Amendment J. Sent
|
|
244
|
+
// here and nowhere else: pairing is the ceremony where a human is
|
|
245
|
+
// already deciding whether to trust this upstream, so a key learned
|
|
246
|
+
// here rides a decision that has been made rather than inventing one.
|
|
247
|
+
...this.#deps.controlPlanePublic === void 0 ? {} : { controlPlanePublic: this.#deps.controlPlanePublic }
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Mint a code, remember the keys it stands for, and send the human away.
|
|
252
|
+
*
|
|
253
|
+
* Nothing is decided here. The relay holds an assertion — "this keypair
|
|
254
|
+
* would like to be a machine" — for ten minutes, and the decision happens
|
|
255
|
+
* where the person is signed in.
|
|
256
|
+
*/
|
|
257
|
+
async #pairStart(request) {
|
|
258
|
+
const codes = this.#deps.pairingCodes;
|
|
259
|
+
const verificationUrl = this.#deps.verificationUrl;
|
|
260
|
+
if (!codes || verificationUrl === void 0) {
|
|
261
|
+
return fail(
|
|
262
|
+
501,
|
|
263
|
+
"bad-request",
|
|
264
|
+
"this relay does not offer device-code pairing"
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (!verifyPublicIdentity(request.device)) {
|
|
268
|
+
return fail(400, "bad-request", "the device identity is not consistent");
|
|
269
|
+
}
|
|
270
|
+
const pending = {
|
|
271
|
+
deviceCode: newDeviceCode(),
|
|
272
|
+
userCode: newUserCode(),
|
|
273
|
+
device: request.device,
|
|
274
|
+
label: request.daemon.label,
|
|
275
|
+
platform: request.daemon.platform,
|
|
276
|
+
capabilities: request.capabilities,
|
|
277
|
+
expiresAt: this.#deps.now() + PAIRING_CODE_TTL_MS
|
|
278
|
+
};
|
|
279
|
+
if (await codes.put(pending) === "at-capacity") {
|
|
280
|
+
return fail(
|
|
281
|
+
ERROR_STATUS2["rate-limited"],
|
|
282
|
+
"rate-limited",
|
|
283
|
+
PAIRING_BUSY_MESSAGE
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return ok({
|
|
287
|
+
deviceCode: pending.deviceCode,
|
|
288
|
+
userCode: pending.userCode,
|
|
289
|
+
verificationUrl,
|
|
290
|
+
expiresAt: pending.expiresAt,
|
|
291
|
+
// Two seconds: fast enough that approving feels immediate, slow enough
|
|
292
|
+
// that a forgotten terminal is not a load generator.
|
|
293
|
+
pollIntervalMs: 2e3
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Has anybody approved this keypair yet?
|
|
298
|
+
*
|
|
299
|
+
* The answer comes from the **projection** — the control plane's own record
|
|
300
|
+
* of devices a human approved — and never from a flag set here. That is the
|
|
301
|
+
* whole shape of the fence: the dashboard writes the approval to its own
|
|
302
|
+
* database, the hub's projection catches up within a poll, and this notices.
|
|
303
|
+
* No write crosses in either direction.
|
|
304
|
+
*/
|
|
305
|
+
async #pairPoll(request) {
|
|
306
|
+
const codes = this.#deps.pairingCodes;
|
|
307
|
+
if (!codes) {
|
|
308
|
+
return fail(
|
|
309
|
+
501,
|
|
310
|
+
"bad-request",
|
|
311
|
+
"this relay does not offer device-code pairing"
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const pending = await codes.byDeviceCode(request.deviceCode);
|
|
315
|
+
if (!pending) return ok({ status: "expired" });
|
|
316
|
+
const approved = this.#deps.projection.deviceByFingerprint(
|
|
317
|
+
pending.device.identity
|
|
318
|
+
);
|
|
319
|
+
if (!approved) return ok({ status: "pending" });
|
|
320
|
+
const sites = this.#deps.projection.sitesFor(approved.owner);
|
|
321
|
+
await this.#deps.state.seen({
|
|
322
|
+
runnerId: approved.runnerId,
|
|
323
|
+
owner: approved.owner,
|
|
324
|
+
device: pending.device,
|
|
325
|
+
// What this machine said it could run when it asked to pair, so the
|
|
326
|
+
// approval screen and the machines page have an answer in the same
|
|
327
|
+
// moment the device appears. The next heartbeat replaces it.
|
|
328
|
+
capabilities: pending.capabilities,
|
|
329
|
+
// A pairing request describes what a machine *can* run, not what it is
|
|
330
|
+
// holding back — the daemon resolves defaults locally, and the first
|
|
331
|
+
// heartbeat is where that answer arrives.
|
|
332
|
+
withheld: []
|
|
333
|
+
});
|
|
334
|
+
await codes.drop(pending.deviceCode);
|
|
335
|
+
return ok({
|
|
336
|
+
status: "approved",
|
|
337
|
+
runnerId: approved.runnerId,
|
|
338
|
+
owner: approved.owner,
|
|
339
|
+
sites: Object.fromEntries(
|
|
340
|
+
sites.map((record) => [keyId(record.site.identity), record.site])
|
|
341
|
+
),
|
|
342
|
+
// The key every later grant is checked against — Amendment J. Sent
|
|
343
|
+
// here and nowhere else: pairing is the ceremony where a human is
|
|
344
|
+
// already deciding whether to trust this upstream, so a key learned
|
|
345
|
+
// here rides a decision that has been made rather than inventing one.
|
|
346
|
+
...this.#deps.controlPlanePublic === void 0 ? {} : { controlPlanePublic: this.#deps.controlPlanePublic }
|
|
189
347
|
});
|
|
190
348
|
}
|
|
191
349
|
/** Every authenticated call: signature first, then consent, then work. */
|
|
192
|
-
#authed(input, body, schema, run, options = {}) {
|
|
350
|
+
async #authed(input, body, schema, run, options = {}) {
|
|
193
351
|
const signature = RequestSignature.safeParse(input.signature);
|
|
194
352
|
if (!signature.success) {
|
|
195
353
|
return fail(401, "unauthorized", "this request is not signed");
|
|
196
354
|
}
|
|
197
|
-
|
|
355
|
+
let known = await this.#deps.state.presence(signature.data.runnerId);
|
|
356
|
+
let rebuilt = false;
|
|
198
357
|
if (!known) {
|
|
199
|
-
|
|
358
|
+
const approved = this.#deps.projection.deviceFor(signature.data.runnerId);
|
|
359
|
+
if (!approved) {
|
|
360
|
+
return fail(401, "unauthorized", "this runner is not recognised");
|
|
361
|
+
}
|
|
362
|
+
known = {
|
|
363
|
+
...approved,
|
|
364
|
+
lastSeenAt: this.#deps.now(),
|
|
365
|
+
// Not invented. The heartbeat is the authority on what a machine can
|
|
366
|
+
// run, and it is seconds away; claiming a matrix here would be this
|
|
367
|
+
// file guessing about a backend it cannot see.
|
|
368
|
+
capabilities: [],
|
|
369
|
+
withheld: []
|
|
370
|
+
};
|
|
371
|
+
rebuilt = true;
|
|
200
372
|
}
|
|
201
373
|
const failure = verifyRequest({
|
|
202
374
|
identityPublic: known.device.identity,
|
|
@@ -205,8 +377,20 @@ var DaemonPlane = class {
|
|
|
205
377
|
signature: signature.data,
|
|
206
378
|
now: this.#deps.now()
|
|
207
379
|
});
|
|
380
|
+
if (failure === "stale") return this.#clockSkew();
|
|
208
381
|
if (failure) return fail(401, "unauthorized", "signature check failed");
|
|
209
|
-
|
|
382
|
+
if (rebuilt) {
|
|
383
|
+
await this.#deps.state.seen({
|
|
384
|
+
runnerId: known.runnerId,
|
|
385
|
+
owner: known.owner,
|
|
386
|
+
device: known.device,
|
|
387
|
+
capabilities: [],
|
|
388
|
+
// Nothing has described itself yet, so nothing is withheld — the
|
|
389
|
+
// first heartbeat is the authority on both.
|
|
390
|
+
withheld: []
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
const revoked = this.#deps.projection.revokedDevice(known.runnerId);
|
|
210
394
|
if (revoked && options.allowRevoked !== true) {
|
|
211
395
|
return fail(403, "revoked", "routing for this runner has been revoked");
|
|
212
396
|
}
|
|
@@ -217,43 +401,104 @@ var DaemonPlane = class {
|
|
|
217
401
|
}
|
|
218
402
|
return run(parsed.data, known);
|
|
219
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* A clock too far from ours, said plainly and with the number to fix it by.
|
|
406
|
+
*
|
|
407
|
+
* Its own error code rather than a generic `unauthorized`, because it is the
|
|
408
|
+
* one refusal a retry can never fix and an `ntpdate` always can — the same
|
|
409
|
+
* reasoning `version-unsupported` already carries on the daemon side. A
|
|
410
|
+
* daemon that reports this as a generic rejection sends its owner looking at
|
|
411
|
+
* their network.
|
|
412
|
+
*
|
|
413
|
+
* `serverTime` is included so the far side can say *how far off* rather than
|
|
414
|
+
* *that something is wrong*. It is not a disclosure: the heartbeat response
|
|
415
|
+
* returns the same value, and so does every `Date` header.
|
|
416
|
+
*/
|
|
417
|
+
#clockSkew() {
|
|
418
|
+
return clockSkewRefusal(this.#deps.now());
|
|
419
|
+
}
|
|
220
420
|
claim(auth, body) {
|
|
221
|
-
return this.#authed(auth, body, ClaimRequest, (request, device) => {
|
|
421
|
+
return this.#authed(auth, body, ClaimRequest, async (request, device) => {
|
|
222
422
|
if (request.runnerId !== device.runnerId) {
|
|
223
|
-
return fail(
|
|
423
|
+
return fail(403, "forbidden", "runner id does not match the key");
|
|
224
424
|
}
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
425
|
+
const granted = await this.#deps.state.claim({
|
|
426
|
+
runnerId: device.runnerId,
|
|
427
|
+
owner: device.owner,
|
|
428
|
+
device: device.device,
|
|
429
|
+
// Every kind this device can run, from any of its services.
|
|
430
|
+
//
|
|
431
|
+
// This used to send only the *defaults* — the rows an unselected job
|
|
432
|
+
// should reach — beside the whole menu as (kind, service) pairs. A
|
|
433
|
+
// job names no service now, so there is no menu to match against and
|
|
434
|
+
// no unselected case for a default to catch: a device answering a
|
|
435
|
+
// kind at all is a candidate, and which of its services runs the work
|
|
436
|
+
// is resolved from the person's mapping when the grant is signed.
|
|
437
|
+
kinds: new Set(request.capabilities.map((c) => c.kind)),
|
|
438
|
+
// The projection, collapsed to data the store can match on — a
|
|
439
|
+
// predicate does not travel, and a set of (site, owner) pairs is
|
|
440
|
+
// what a route is (cloud_009 §3).
|
|
441
|
+
//
|
|
442
|
+
// A paused consent (cloud_008 finding 48) drops its routes and
|
|
443
|
+
// leaves the rest, which is the whole difference between "we are
|
|
444
|
+
// waiting for you to read something about one site" and "a human cut
|
|
445
|
+
// you off from everything".
|
|
446
|
+
routes: this.#deps.projection.routesFor(device.owner),
|
|
447
|
+
max: request.max,
|
|
448
|
+
leaseMs: this.#deps.leaseMs
|
|
449
|
+
});
|
|
450
|
+
const author = this.#deps.authorGrant;
|
|
451
|
+
if (author === void 0) {
|
|
452
|
+
return ok({ jobs: granted, leaseMs: this.#deps.leaseMs });
|
|
453
|
+
}
|
|
454
|
+
const withGrants = [];
|
|
455
|
+
const refused = [];
|
|
456
|
+
const returned = [];
|
|
457
|
+
for (const job of granted) {
|
|
458
|
+
const siteId = this.#deps.projection.siteIdForKey(job.site);
|
|
459
|
+
const decision = siteId === null ? (
|
|
460
|
+
// A stub naming a site this projection cannot place. Transient
|
|
461
|
+
// rather than permanent: the projection is what is behind, not
|
|
462
|
+
// the job.
|
|
463
|
+
{ declined: { permanent: false, reason: "unknown-site" } }
|
|
464
|
+
) : await author({
|
|
465
|
+
job,
|
|
466
|
+
siteId,
|
|
467
|
+
// Straight off the stub. A relay that computed this would be
|
|
468
|
+
// choosing which site a grant says it is for, which is the
|
|
469
|
+
// one thing the signature exists to take out of its hands.
|
|
470
|
+
siteKey: job.site,
|
|
471
|
+
// The site's own purpose, straight off the stub. A relay does
|
|
472
|
+
// not interpret it — it does not hold the manifest and does
|
|
473
|
+
// not hold the mapping; it carries the site's word to the one
|
|
474
|
+
// party that can join them.
|
|
475
|
+
...job.purpose === void 0 ? {} : { purpose: job.purpose },
|
|
476
|
+
owner: device.owner,
|
|
477
|
+
runnerId: device.runnerId,
|
|
478
|
+
capabilities: request.capabilities
|
|
479
|
+
});
|
|
480
|
+
if (decision.granted === void 0) {
|
|
481
|
+
const lease = { jobId: job.id, leaseId: job.lease.id };
|
|
482
|
+
(decision.declined.permanent ? refused : returned).push(lease);
|
|
235
483
|
continue;
|
|
236
484
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
485
|
+
withGrants.push({ ...job, grant: decision.granted });
|
|
486
|
+
}
|
|
487
|
+
if (refused.length > 0) {
|
|
488
|
+
await this.#deps.state.releaseLeases({
|
|
240
489
|
runnerId: device.runnerId,
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
id: leaseId,
|
|
251
|
-
runnerId: device.runnerId,
|
|
252
|
-
expiresAt: job.claimedBy.leaseExpiresAt
|
|
253
|
-
}
|
|
490
|
+
leases: refused,
|
|
491
|
+
reason: "refused"
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (returned.length > 0) {
|
|
495
|
+
await this.#deps.state.releaseLeases({
|
|
496
|
+
runnerId: device.runnerId,
|
|
497
|
+
leases: returned,
|
|
498
|
+
retryAfter: this.#deps.now() + RETRY_AFTER_MS
|
|
254
499
|
});
|
|
255
500
|
}
|
|
256
|
-
return ok({ jobs:
|
|
501
|
+
return ok({ jobs: withGrants, leaseMs: this.#deps.leaseMs });
|
|
257
502
|
});
|
|
258
503
|
}
|
|
259
504
|
/**
|
|
@@ -267,20 +512,14 @@ var DaemonPlane = class {
|
|
|
267
512
|
* awaiting-payload clock says otherwise.
|
|
268
513
|
*/
|
|
269
514
|
fetch(auth, body) {
|
|
270
|
-
return this.#authed(auth, body, FetchRequest, (request, device) => {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
if (!job.payload) {
|
|
280
|
-
return fail(409, "not-ready", "the site has not sealed this job yet");
|
|
281
|
-
}
|
|
282
|
-
job.state = "running";
|
|
283
|
-
return ok({ envelope: job.payload });
|
|
515
|
+
return this.#authed(auth, body, FetchRequest, async (request, device) => {
|
|
516
|
+
const taken = await this.#deps.state.takePayload({
|
|
517
|
+
jobId: request.jobId,
|
|
518
|
+
runnerId: device.runnerId,
|
|
519
|
+
leaseId: request.leaseId
|
|
520
|
+
});
|
|
521
|
+
if ("refused" in taken) return REFUSALS[taken.refused];
|
|
522
|
+
return ok({ envelope: taken.envelope });
|
|
284
523
|
});
|
|
285
524
|
}
|
|
286
525
|
/**
|
|
@@ -293,19 +532,18 @@ var DaemonPlane = class {
|
|
|
293
532
|
* only verifiable there.
|
|
294
533
|
*/
|
|
295
534
|
result(auth, body) {
|
|
296
|
-
return this.#authed(auth, body, ResultRequest, (request, device) => {
|
|
297
|
-
const
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return ok({ accepted: true, state: job.state });
|
|
535
|
+
return this.#authed(auth, body, ResultRequest, async (request, device) => {
|
|
536
|
+
const bytes = envelopeBytes(request.envelope);
|
|
537
|
+
if (bytes > MAX_ENVELOPE_BYTES2) return tooLargeRefusal(bytes);
|
|
538
|
+
const recorded = await this.#deps.state.complete({
|
|
539
|
+
jobId: request.jobId,
|
|
540
|
+
runnerId: device.runnerId,
|
|
541
|
+
leaseId: request.leaseId,
|
|
542
|
+
envelope: request.envelope,
|
|
543
|
+
disposition: request.disposition
|
|
544
|
+
});
|
|
545
|
+
if ("refused" in recorded) return REFUSALS[recorded.refused];
|
|
546
|
+
return ok(recorded);
|
|
309
547
|
});
|
|
310
548
|
}
|
|
311
549
|
heartbeat(auth, body) {
|
|
@@ -313,41 +551,110 @@ var DaemonPlane = class {
|
|
|
313
551
|
auth,
|
|
314
552
|
body,
|
|
315
553
|
HeartbeatRequest,
|
|
316
|
-
(request, device) => {
|
|
554
|
+
async (request, device) => {
|
|
555
|
+
const belowFloor = this.#deps.daemonFloor === void 0 ? null : checkDaemonFloor({
|
|
556
|
+
daemonVersion: request.daemonVersion,
|
|
557
|
+
floor: this.#deps.daemonFloor,
|
|
558
|
+
upgradeCommand: UPGRADE_COMMAND
|
|
559
|
+
});
|
|
560
|
+
if (belowFloor !== null) {
|
|
561
|
+
return {
|
|
562
|
+
status: ERROR_STATUS2["daemon-below-floor"],
|
|
563
|
+
body: {
|
|
564
|
+
error: belowFloor.error,
|
|
565
|
+
message: belowFloor.message,
|
|
566
|
+
floor: belowFloor.floor
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
}
|
|
317
570
|
const now = this.#deps.now();
|
|
318
|
-
this.#deps.state.sweep(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
device.owner,
|
|
322
|
-
|
|
571
|
+
await this.#deps.state.sweep();
|
|
572
|
+
await this.#deps.state.seen({
|
|
573
|
+
runnerId: device.runnerId,
|
|
574
|
+
owner: device.owner,
|
|
575
|
+
device: device.device,
|
|
576
|
+
capabilities: request.capabilities,
|
|
577
|
+
// Arrives on the same beat as the matrix and goes stale with it.
|
|
578
|
+
// A daemon that resolves a contended kind stops sending it here,
|
|
579
|
+
// which is what retires the owner's prompt to choose.
|
|
580
|
+
withheld: request.withheld
|
|
581
|
+
});
|
|
582
|
+
const pinned = this.#deps.projection.sitesFor(device.owner);
|
|
583
|
+
const sites = Object.fromEntries(
|
|
584
|
+
pinned.map((record) => [keyId(record.site.identity), record.site])
|
|
585
|
+
);
|
|
586
|
+
const successions = Object.fromEntries(
|
|
587
|
+
pinned.filter((record) => (record.succeeds?.length ?? 0) > 0).map((record) => [
|
|
588
|
+
keyId(record.site.identity),
|
|
589
|
+
{
|
|
590
|
+
succeeds: record.succeeds ?? [],
|
|
591
|
+
...record.retiringUntil === void 0 ? {} : { retiringUntil: record.retiringUntil }
|
|
592
|
+
}
|
|
593
|
+
])
|
|
323
594
|
);
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
595
|
+
const rotations = Object.keys(successions).length > 0 ? { successions } : {};
|
|
596
|
+
const awaitingConsent = pinned.filter(
|
|
597
|
+
(record) => !this.#deps.projection.mayRouteFor(device.owner, record.siteId)
|
|
598
|
+
).map((record) => keyId(record.site.identity));
|
|
599
|
+
if (pinned.length === 0) {
|
|
600
|
+
return ok({
|
|
601
|
+
sites,
|
|
602
|
+
...rotations,
|
|
603
|
+
...updateOfferFor({
|
|
604
|
+
offer: this.#deps.updateOffer,
|
|
605
|
+
daemonVersion: request.daemonVersion
|
|
606
|
+
}),
|
|
607
|
+
awaitingConsent,
|
|
608
|
+
cancel: [],
|
|
609
|
+
lost: request.activeLeases.map((lease) => ({
|
|
610
|
+
jobId: lease.jobId,
|
|
611
|
+
leaseId: lease.leaseId
|
|
612
|
+
})),
|
|
613
|
+
serverTime: now
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
const cancel = await this.#deps.state.cancelRequests(device.runnerId);
|
|
617
|
+
const { lost } = await this.#deps.state.renewLeases({
|
|
618
|
+
runnerId: device.runnerId,
|
|
619
|
+
leases: request.activeLeases,
|
|
620
|
+
leaseMs: this.#deps.leaseMs
|
|
621
|
+
});
|
|
330
622
|
return ok({
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
623
|
+
sites,
|
|
624
|
+
...rotations,
|
|
625
|
+
/**
|
|
626
|
+
* The update offer — B053, and the ONLY way this field is set.
|
|
627
|
+
*
|
|
628
|
+
* `updateOfferFor` applies `mayOfferUpdate` inside itself and
|
|
629
|
+
* returns a spreadable object, so there is no `updateTo:` anywhere
|
|
630
|
+
* for a later hand to copy to a third return site. There are
|
|
631
|
+
* already two, which is how a remember-to-check rule fails.
|
|
632
|
+
*
|
|
633
|
+
* HeartbeatResponse is `.strict()`: a wrong emission is not a bad
|
|
634
|
+
* offer, it is every pre-.83 daemon rejecting every heartbeat.
|
|
635
|
+
*/
|
|
636
|
+
...updateOfferFor({
|
|
637
|
+
offer: this.#deps.updateOffer,
|
|
638
|
+
daemonVersion: request.daemonVersion
|
|
639
|
+
}),
|
|
640
|
+
awaitingConsent,
|
|
641
|
+
cancel,
|
|
334
642
|
lost,
|
|
335
643
|
serverTime: now
|
|
336
644
|
});
|
|
337
|
-
}
|
|
338
|
-
{ allowRevoked: true }
|
|
645
|
+
}
|
|
339
646
|
);
|
|
340
647
|
}
|
|
341
648
|
release(auth, body) {
|
|
342
|
-
return this.#authed(auth, body, ReleaseRequest, (request, device) => {
|
|
343
|
-
const released =
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
this
|
|
349
|
-
|
|
350
|
-
}
|
|
649
|
+
return this.#authed(auth, body, ReleaseRequest, async (request, device) => {
|
|
650
|
+
const released = await this.#deps.state.releaseLeases({
|
|
651
|
+
runnerId: device.runnerId,
|
|
652
|
+
leases: request.leases,
|
|
653
|
+
// Was dropped here — cloud_008 §2.1. `reason` is on the wire, the
|
|
654
|
+
// schema's own docstring says an upstream MUST record `refused`, and
|
|
655
|
+
// this handler read every other field.
|
|
656
|
+
reason: request.reason
|
|
657
|
+
});
|
|
351
658
|
return ok({ released });
|
|
352
659
|
});
|
|
353
660
|
}
|
|
@@ -387,9 +694,9 @@ function jobRow(job, now) {
|
|
|
387
694
|
<td>${job.result ? escape(job.disposition ?? "?") : "<span class='dim'>\u2014</span>"}</td>
|
|
388
695
|
</tr>`;
|
|
389
696
|
}
|
|
390
|
-
function debugPage(state, now) {
|
|
391
|
-
const jobs = state.jobs();
|
|
392
|
-
const devices = state.everyone();
|
|
697
|
+
async function debugPage(state, now, routesFor) {
|
|
698
|
+
const jobs = await state.jobs();
|
|
699
|
+
const devices = await state.everyone();
|
|
393
700
|
return `<!doctype html>
|
|
394
701
|
<html><head><meta charset="utf-8"><title>byollm relay \u2014 debug</title>
|
|
395
702
|
<meta http-equiv="refresh" content="1">
|
|
@@ -427,7 +734,7 @@ ${devices.length ? devices.map(
|
|
|
427
734
|
<td>${escape(d.owner)}</td>
|
|
428
735
|
<td><code>${escape(fingerprintOf(d.device))}</code></td>
|
|
429
736
|
<td>${String(Math.max(0, now - d.lastSeenAt))}ms ago</td>
|
|
430
|
-
<td>${d.
|
|
737
|
+
<td>${routesFor && !routesFor.consents(d.owner) ? "<b style='color:#c00'>no consent</b>" : "active"}</td>
|
|
431
738
|
</tr>`
|
|
432
739
|
).join("\n") : `<tr><td colspan="5" class="dim">no devices connected</td></tr>`}
|
|
433
740
|
</table>
|
|
@@ -435,7 +742,12 @@ ${devices.length ? devices.map(
|
|
|
435
742
|
}
|
|
436
743
|
|
|
437
744
|
// src/fixture.ts
|
|
438
|
-
import {
|
|
745
|
+
import {
|
|
746
|
+
MAX_SUCCESSION_CHAIN,
|
|
747
|
+
Succession,
|
|
748
|
+
PublicIdentity as PublicIdentity2,
|
|
749
|
+
keyId as keyIdOf
|
|
750
|
+
} from "@byollm/protocol";
|
|
439
751
|
import { z as z2 } from "zod";
|
|
440
752
|
var SiteRecord = z2.object({
|
|
441
753
|
/** How the control plane names the site. */
|
|
@@ -447,13 +759,61 @@ var SiteRecord = z2.object({
|
|
|
447
759
|
* signatures and seals nothing. This is the key-exchange half of consent
|
|
448
760
|
* (cloud_004 §3), and both endpoints pin what they receive.
|
|
449
761
|
*/
|
|
450
|
-
site: PublicIdentity2
|
|
762
|
+
site: PublicIdentity2,
|
|
763
|
+
/**
|
|
764
|
+
* How this site's current key can be traced back to one a daemon holds —
|
|
765
|
+
* byollm_009 Amendment C, ordered oldest last.
|
|
766
|
+
*
|
|
767
|
+
* A list rather than one predecessor because a daemon offline across two
|
|
768
|
+
* rotations holds K1 and meets K3: with a single predecessor it could not
|
|
769
|
+
* verify K3 without K2's record, so it would have to re-pair over
|
|
770
|
+
* housekeeping it did not ask for. The proofs are small, self-verifying,
|
|
771
|
+
* and kept indefinitely for the same reason.
|
|
772
|
+
*
|
|
773
|
+
* **The relay distributes these and cannot mint one.** Each is a signature
|
|
774
|
+
* by a key it does not hold, which is what lets rotation be automatic
|
|
775
|
+
* without becoming the substitution `SITES_LOCALLY_APPROVED` refuses.
|
|
776
|
+
*/
|
|
777
|
+
succeeds: z2.array(Succession).max(MAX_SUCCESSION_CHAIN).optional(),
|
|
778
|
+
/**
|
|
779
|
+
* Until when the retired key may still sign work — epoch ms.
|
|
780
|
+
*
|
|
781
|
+
* Absent on a site that has never rotated. The daemon holds its own clock
|
|
782
|
+
* against this for the reason it holds its own allowlist: a projection
|
|
783
|
+
* that could extend the window indefinitely would be a two-key site
|
|
784
|
+
* forever, decided by the party the design does not trust.
|
|
785
|
+
*/
|
|
786
|
+
retiringUntil: z2.number().int().positive().optional()
|
|
451
787
|
}).strict();
|
|
452
788
|
var ConsentRecord = z2.object({
|
|
453
789
|
/** The user, as the control plane identifies them. */
|
|
454
790
|
owner: z2.string().min(1),
|
|
455
791
|
/** Which site this consent is for. Scoped: consent is never global. */
|
|
456
|
-
siteId: z2.string().min(1)
|
|
792
|
+
siteId: z2.string().min(1),
|
|
793
|
+
/**
|
|
794
|
+
* The consent stands, and nothing routes under it — cloud_008 finding 48.
|
|
795
|
+
*
|
|
796
|
+
* The disclosure this user agreed to no longer describes their
|
|
797
|
+
* arrangements: they read that their prompts stay on machines they own,
|
|
798
|
+
* and they have since been added to a roster whose owner can read them.
|
|
799
|
+
* Until they have been shown the other sentence and clicked, their work
|
|
800
|
+
* does not move.
|
|
801
|
+
*
|
|
802
|
+
* **A third state, because the two we had are both wrong here.** Dropping
|
|
803
|
+
* the consent makes `consentFor` return null, and the daemon plane reads
|
|
804
|
+
* exactly that as revoked: heartbeat answers `revoked: true` with `lost:
|
|
805
|
+
* all`, and the daemon prints "this runner was revoked" and *deletes its
|
|
806
|
+
* pairing*. So a user whose team changed a setting would be told a human
|
|
807
|
+
* cut them off, lose their pinned keys, and have to re-run `byollm
|
|
808
|
+
* connect` after re-consenting. Under cloud_009 that is worse still: the
|
|
809
|
+
* pairing is keyed by origin, so one stale consent would drop the pairing
|
|
810
|
+
* for every other site reached through that hub.
|
|
811
|
+
*
|
|
812
|
+
* Reporting it as revoked is the same falsehood finding 48 exists to
|
|
813
|
+
* delete, told one layer down. So the record stays, the relationship
|
|
814
|
+
* stays, and the routing stops.
|
|
815
|
+
*/
|
|
816
|
+
paused: z2.boolean().default(false)
|
|
457
817
|
}).strict();
|
|
458
818
|
var RosterRecord = z2.object({
|
|
459
819
|
/** Stable id for the group, used only inside the relay. */
|
|
@@ -469,7 +829,23 @@ var DeviceRecord = z2.object({
|
|
|
469
829
|
/** The id the control plane assigned — the device does not choose it. */
|
|
470
830
|
runnerId: z2.string().min(1),
|
|
471
831
|
/** The keys a human compared a fingerprint of before approving. */
|
|
472
|
-
device: PublicIdentity2
|
|
832
|
+
device: PublicIdentity2,
|
|
833
|
+
/**
|
|
834
|
+
* Whether this device — this one — has been revoked.
|
|
835
|
+
*
|
|
836
|
+
* Optional because a control plane that predates it says nothing, and
|
|
837
|
+
* saying nothing must read as "not revoked": the alternative is a missing
|
|
838
|
+
* field stopping every device on the fleet, which is the failure mode this
|
|
839
|
+
* whole entry exists to end.
|
|
840
|
+
*
|
|
841
|
+
* Device-scoped by ruling (2026-09-03). Revocation used to be answered
|
|
842
|
+
* from the owner's route-revocation list, so an account with any
|
|
843
|
+
* revocation on record and no live site consents refused **every** device
|
|
844
|
+
* it owned — including one paired thirty seconds earlier, whose daemon
|
|
845
|
+
* then deleted its own pairings file. Revoking an experiment and pairing a
|
|
846
|
+
* replacement, which is the ordinary first hour, killed the replacement.
|
|
847
|
+
*/
|
|
848
|
+
revoked: z2.boolean().optional()
|
|
473
849
|
}).strict();
|
|
474
850
|
var RevocationRecord = z2.object({ owner: z2.string().min(1), siteId: z2.string().min(1) }).strict();
|
|
475
851
|
var RelayFixture = z2.object({
|
|
@@ -531,7 +907,15 @@ var Projection = class {
|
|
|
531
907
|
deviceByFingerprint(identityPublic) {
|
|
532
908
|
return this.#fixture.devices.find((d) => d.device.identity === identityPublic) ?? null;
|
|
533
909
|
}
|
|
534
|
-
/**
|
|
910
|
+
/**
|
|
911
|
+
* The consent binding this owner to this site, if it exists and stands.
|
|
912
|
+
*
|
|
913
|
+
* **Liveness, not routing.** A paused consent is returned here: the
|
|
914
|
+
* relationship exists, the daemon is not revoked, the pairing stands. Ask
|
|
915
|
+
* {@link Projection.mayRouteFor} before moving anybody's work — the two
|
|
916
|
+
* questions have different answers and one method answering both is how a
|
|
917
|
+
* paused user would quietly start routing again.
|
|
918
|
+
*/
|
|
535
919
|
consentFor(owner, siteId) {
|
|
536
920
|
const revoked = this.#fixture.revoked.some(
|
|
537
921
|
(r) => r.owner === owner && r.siteId === siteId
|
|
@@ -541,6 +925,167 @@ var Projection = class {
|
|
|
541
925
|
(c) => c.owner === owner && c.siteId === siteId
|
|
542
926
|
) ?? null;
|
|
543
927
|
}
|
|
928
|
+
/**
|
|
929
|
+
* Every site this owner may route with — cloud_009 §3.
|
|
930
|
+
*
|
|
931
|
+
* The set a pairing covers, and the set a claim will filter on. Consent
|
|
932
|
+
* decides it, which is the sentence the whole design rests on: a site
|
|
933
|
+
* appears here because a human clicked, never because a site asked to be
|
|
934
|
+
* here and never because a daemon named it.
|
|
935
|
+
*
|
|
936
|
+
* **Paused sites are here, and that is deliberate** — cloud_008 finding 48
|
|
937
|
+
* as ratified. A paused consent routes nothing and keeps its pin: the
|
|
938
|
+
* relationship stands, the key the daemon compared a fingerprint of stays
|
|
939
|
+
* pinned, and re-consenting never costs a re-pair. Written the other way
|
|
940
|
+
* round first, and three of the paused tests failed by refusing to pair at
|
|
941
|
+
* all — which is the trap the finding is about, arriving through the door
|
|
942
|
+
* marked "be stricter".
|
|
943
|
+
*
|
|
944
|
+
* So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
|
|
945
|
+
* questions with different answers, kept apart for the same reason
|
|
946
|
+
* `consentFor` and `mayRouteFor` are: one method answering both is how a
|
|
947
|
+
* paused user quietly starts routing again, or quietly loses their machine.
|
|
948
|
+
*
|
|
949
|
+
* Sorted by site id so two calls with the same projection produce the same
|
|
950
|
+
* answer: this ends up in a pairings file and in a fingerprint list a human
|
|
951
|
+
* compares by eye, and an order that drifts between polls is a diff nobody
|
|
952
|
+
* can read.
|
|
953
|
+
*/
|
|
954
|
+
sitesFor(owner) {
|
|
955
|
+
return this.#fixture.sites.filter((site) => this.consentFor(owner, site.siteId) !== null).sort((a, b) => a.siteId < b.siteId ? -1 : 1);
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Which registered site owns this identity key id?
|
|
959
|
+
*
|
|
960
|
+
* A stub names its site by *key id* (Amendment A §A.3) so a daemon can
|
|
961
|
+
* check it against a pinned key without a lookup. A control plane knows
|
|
962
|
+
* sites by their account id. This is the one place that holds both, so it
|
|
963
|
+
* is the one place that joins them — a control plane asked to accept key
|
|
964
|
+
* ids would need its own copy of the registry.
|
|
965
|
+
*
|
|
966
|
+
* `null` for a key id no registered site carries, which is a projection
|
|
967
|
+
* that is behind rather than a job that is wrong.
|
|
968
|
+
*/
|
|
969
|
+
siteIdForKey(keyId3) {
|
|
970
|
+
return this.#fixture.sites.find(
|
|
971
|
+
(record) => keyIdOf(record.site.identity) === keyId3
|
|
972
|
+
)?.siteId ?? null;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* May this owner's work move for this site, right now?
|
|
976
|
+
*
|
|
977
|
+
* Consent exists, was not revoked, and is not paused. The routing question,
|
|
978
|
+
* kept apart from {@link Projection.consentFor}'s liveness one so that a
|
|
979
|
+
* caller has to pick which it means.
|
|
980
|
+
*/
|
|
981
|
+
mayRouteFor(owner, siteId) {
|
|
982
|
+
const consent = this.consentFor(owner, siteId);
|
|
983
|
+
return consent !== null && !consent.paused;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Has *this device* been revoked — ruled 2026-09-03?
|
|
987
|
+
*
|
|
988
|
+
* This method has now been wrong in both directions, which is why it reads
|
|
989
|
+
* the way it does.
|
|
990
|
+
*
|
|
991
|
+
* First it answered "is there nothing to serve", so an empty or half-written
|
|
992
|
+
* projection was indistinguishable from a human's decision and cost every
|
|
993
|
+
* daemon its pinned keys. The fix asked for evidence — a revocation on
|
|
994
|
+
* record — but asked it of the **owner**, and added
|
|
995
|
+
* `sitesFor(owner).length > 0` as a softener. That produced the opposite
|
|
996
|
+
* failure: an account with any revocation and no live consents refused every
|
|
997
|
+
* device it had, one paired seconds ago included.
|
|
998
|
+
*
|
|
999
|
+
* So: revocation is a fact about one device, never a mood about an owner.
|
|
1000
|
+
* This looks up the device that signed the request and reports what the
|
|
1001
|
+
* control plane says about *it*.
|
|
1002
|
+
*
|
|
1003
|
+
* The softener is gone with it. A guard whose answer changes with unrelated
|
|
1004
|
+
* state is not a guard — enabling a site must never be the thing that
|
|
1005
|
+
* un-revokes a machine, and under the old shape it was exactly that.
|
|
1006
|
+
*
|
|
1007
|
+
* A projection that knows nothing about a runner still says nothing here;
|
|
1008
|
+
* `deviceFor` is what refuses an unknown one, with 401, which is a different
|
|
1009
|
+
* sentence for a different situation.
|
|
1010
|
+
*
|
|
1011
|
+
* REVOCATION_IMMEDIATE is untouched: revoking device A still stops A on its
|
|
1012
|
+
* next call. It stops stopping B and C.
|
|
1013
|
+
*/
|
|
1014
|
+
revokedDevice(runnerId) {
|
|
1015
|
+
const device = this.#fixture.devices.find(
|
|
1016
|
+
(record) => record.runnerId === runnerId
|
|
1017
|
+
);
|
|
1018
|
+
return device?.revoked === true;
|
|
1019
|
+
}
|
|
1020
|
+
/** Whether this pair is consented and paused — what heartbeat reports. */
|
|
1021
|
+
pausedFor(owner, siteId) {
|
|
1022
|
+
return this.consentFor(owner, siteId)?.paused === true;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Every (site, owner) route this device may run — cloud_009 §3.
|
|
1026
|
+
*
|
|
1027
|
+
* The claim filter, collapsed to data a store can match on. `routableOwners`
|
|
1028
|
+
* was this for one site; the hub needs it for the set, and the shape had to
|
|
1029
|
+
* change rather than repeat, because **a set of sites and a set of owners
|
|
1030
|
+
* multiply**. A device whose owner consented to site A, serving a roster
|
|
1031
|
+
* member who consented to site B, appears in both sets and has no consented
|
|
1032
|
+
* route between them. Pairs cannot express a route nobody agreed to.
|
|
1033
|
+
*
|
|
1034
|
+
* Both halves of the rule are here, and neither was enforced before finding
|
|
1035
|
+
* 48's work:
|
|
1036
|
+
*
|
|
1037
|
+
* - **This machine's owner** must have a live consent for the site, or
|
|
1038
|
+
* nothing of that site's runs here at all — including a roster member's
|
|
1039
|
+
* work. The roster says whose jobs may land on this machine; consent says
|
|
1040
|
+
* whether this machine is available to that site.
|
|
1041
|
+
* - **Each job's owner** must have one too. That check did not exist:
|
|
1042
|
+
* consent was enforced by the daemon plane's blanket revoked guard, which
|
|
1043
|
+
* asks only about the claiming device's owner, so a roster member who
|
|
1044
|
+
* never consented to a site could have their work claimed by their admin's
|
|
1045
|
+
* machine — `CONSENT_BEFORE_ROUTE` read the other way round.
|
|
1046
|
+
*/
|
|
1047
|
+
routesFor(deviceOwner) {
|
|
1048
|
+
const routes = /* @__PURE__ */ new Set();
|
|
1049
|
+
for (const site of this.#fixture.sites) {
|
|
1050
|
+
if (!this.mayRouteFor(deviceOwner, site.siteId)) continue;
|
|
1051
|
+
for (const owner of this.ownersRunnableBy(deviceOwner)) {
|
|
1052
|
+
if (!this.mayRouteFor(owner, site.siteId)) continue;
|
|
1053
|
+
routes.add(routeKey(site.siteId, owner));
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return routes;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Every owner whose work this device's owner may run, as a list.
|
|
1060
|
+
*
|
|
1061
|
+
* The same question {@link mayRunFor} answers, asked in the direction a
|
|
1062
|
+
* *store* can use. That difference is the crux of making `claim` atomic
|
|
1063
|
+
* (cloud_006 §3.2).
|
|
1064
|
+
*
|
|
1065
|
+
* Today `claim` scans every job and calls `mayRunFor` per candidate, which
|
|
1066
|
+
* works because the projection is a local object. A shared routing store
|
|
1067
|
+
* cannot do that: the filter has to travel to the store, and a predicate
|
|
1068
|
+
* does not travel — you cannot send a closure to Valkey. So the projection
|
|
1069
|
+
* is collapsed to **data** here and handed over as a set the store can
|
|
1070
|
+
* match on.
|
|
1071
|
+
*
|
|
1072
|
+
* That the collapse is possible at all is a property of the design worth
|
|
1073
|
+
* noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
|
|
1074
|
+
* computation over the jobs. If it ever became job-dependent — "may run
|
|
1075
|
+
* work of this size", say — an atomic claim would stop being expressible,
|
|
1076
|
+
* and that is the moment to argue rather than to add a parameter.
|
|
1077
|
+
*
|
|
1078
|
+
* The owner is always included: a device runs its owner's work, and the
|
|
1079
|
+
* relay checks that before it checks a roster.
|
|
1080
|
+
*/
|
|
1081
|
+
ownersRunnableBy(deviceOwner) {
|
|
1082
|
+
const owners = /* @__PURE__ */ new Set([deviceOwner]);
|
|
1083
|
+
for (const roster of this.#fixture.rosters) {
|
|
1084
|
+
if (roster.owner !== deviceOwner) continue;
|
|
1085
|
+
for (const member of roster.members) owners.add(member);
|
|
1086
|
+
}
|
|
1087
|
+
return [...owners];
|
|
1088
|
+
}
|
|
544
1089
|
/**
|
|
545
1090
|
* May this device's owner run work belonging to `jobOwner`?
|
|
546
1091
|
*
|
|
@@ -560,6 +1105,10 @@ var Projection = class {
|
|
|
560
1105
|
|
|
561
1106
|
// src/site-plane.ts
|
|
562
1107
|
import {
|
|
1108
|
+
MAX_ENVELOPE_BYTES as MAX_ENVELOPE_BYTES3,
|
|
1109
|
+
PROTOCOL_VERSION as PROTOCOL_VERSION2,
|
|
1110
|
+
envelopeBytes as envelopeBytes2,
|
|
1111
|
+
keyId as keyId2,
|
|
563
1112
|
JobStub,
|
|
564
1113
|
RequestSignature as RequestSignature2,
|
|
565
1114
|
SealedEnvelope,
|
|
@@ -567,6 +1116,7 @@ import {
|
|
|
567
1116
|
} from "@byollm/protocol";
|
|
568
1117
|
import { z as z3 } from "zod";
|
|
569
1118
|
var EnqueueRequest = z3.object({
|
|
1119
|
+
protocolVersion: z3.literal(PROTOCOL_VERSION2),
|
|
570
1120
|
siteId: z3.string().min(1),
|
|
571
1121
|
/**
|
|
572
1122
|
* Everything the relay learns about the job.
|
|
@@ -578,11 +1128,17 @@ var EnqueueRequest = z3.object({
|
|
|
578
1128
|
stub: JobStub
|
|
579
1129
|
}).strict();
|
|
580
1130
|
var PayloadRequest = z3.object({
|
|
1131
|
+
protocolVersion: z3.literal(PROTOCOL_VERSION2),
|
|
581
1132
|
siteId: z3.string().min(1),
|
|
582
1133
|
jobId: z3.string().min(1),
|
|
583
1134
|
/** Sealed to the claiming device. Opaque to us and to the schema. */
|
|
584
1135
|
envelope: SealedEnvelope
|
|
585
1136
|
}).strict();
|
|
1137
|
+
var CancelRequest = z3.object({
|
|
1138
|
+
protocolVersion: z3.literal(PROTOCOL_VERSION2),
|
|
1139
|
+
siteId: z3.string().min(1),
|
|
1140
|
+
jobId: z3.string().min(1)
|
|
1141
|
+
}).strict();
|
|
586
1142
|
var QueryRequest = z3.object({ siteId: z3.string().min(1) }).strict();
|
|
587
1143
|
var ok2 = (body) => ({ status: 200, body });
|
|
588
1144
|
var fail2 = (status, error, message) => ({
|
|
@@ -608,7 +1164,7 @@ var SitePlane = class {
|
|
|
608
1164
|
* stranger presence, claims and lease ids, and "who is online right now" is
|
|
609
1165
|
* exactly the fact a blind relay is otherwise so careful not to reveal.
|
|
610
1166
|
*/
|
|
611
|
-
#authed(auth, body, schema, siteIdOf, run) {
|
|
1167
|
+
async #authed(auth, body, schema, siteIdOf, run) {
|
|
612
1168
|
const signature = RequestSignature2.safeParse(auth.signature);
|
|
613
1169
|
if (!signature.success) {
|
|
614
1170
|
return fail2(401, "unauthorized", "this request is not signed");
|
|
@@ -618,23 +1174,30 @@ var SitePlane = class {
|
|
|
618
1174
|
if (!site) {
|
|
619
1175
|
return fail2(401, "unauthorized", "this site is not registered");
|
|
620
1176
|
}
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1177
|
+
const acceptable = [
|
|
1178
|
+
site.site.identity,
|
|
1179
|
+
...site.retiringUntil !== void 0 && this.#deps.now() < site.retiringUntil ? (site.succeeds ?? []).map((link) => link.identity.identity) : []
|
|
1180
|
+
];
|
|
1181
|
+
let failure = "bad-signature";
|
|
1182
|
+
for (const identityPublic of acceptable) {
|
|
1183
|
+
failure = verifySiteRequest({
|
|
1184
|
+
identityPublic,
|
|
1185
|
+
endpoint: auth.endpoint,
|
|
1186
|
+
body: auth.rawBody,
|
|
1187
|
+
signature: signature.data,
|
|
1188
|
+
now: this.#deps.now()
|
|
1189
|
+
});
|
|
1190
|
+
if (!failure) break;
|
|
1191
|
+
if (failure === "stale") break;
|
|
1192
|
+
}
|
|
1193
|
+
if (failure === "stale") return clockSkewRefusal(this.#deps.now());
|
|
628
1194
|
if (failure) return fail2(401, "unauthorized", "signature check failed");
|
|
629
1195
|
const parsed = schema.safeParse(body);
|
|
630
1196
|
if (!parsed.success || parsed.data === void 0) {
|
|
631
1197
|
return fail2(400, "bad-request", "request failed schema validation");
|
|
632
1198
|
}
|
|
633
1199
|
if (siteIdOf(parsed.data) !== siteId) {
|
|
634
|
-
return fail2(403, "
|
|
635
|
-
}
|
|
636
|
-
if (siteId !== this.#deps.routesFor) {
|
|
637
|
-
return fail2(403, "unauthorized", "this relay does not route for you");
|
|
1200
|
+
return fail2(403, "forbidden", "that is not your site");
|
|
638
1201
|
}
|
|
639
1202
|
return run(parsed.data, siteId);
|
|
640
1203
|
}
|
|
@@ -644,12 +1207,61 @@ var SitePlane = class {
|
|
|
644
1207
|
body,
|
|
645
1208
|
EnqueueRequest,
|
|
646
1209
|
(request) => request.siteId,
|
|
647
|
-
(request, siteId) => {
|
|
648
|
-
const
|
|
1210
|
+
async (request, siteId) => {
|
|
1211
|
+
const registered = this.#deps.projection.siteFor(siteId);
|
|
1212
|
+
if (!registered || keyId2(registered.site.identity) !== request.stub.site) {
|
|
1213
|
+
return fail2(
|
|
1214
|
+
403,
|
|
1215
|
+
// V1-13, and one of the five the ruling itself named: an
|
|
1216
|
+
// identified site claiming another site's stub is `forbidden`.
|
|
1217
|
+
"forbidden",
|
|
1218
|
+
"that stub does not name the site that signed it"
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
let answer;
|
|
1222
|
+
try {
|
|
1223
|
+
answer = await this.#deps.satisfiable?.({
|
|
1224
|
+
siteId,
|
|
1225
|
+
owner: request.stub.owner,
|
|
1226
|
+
purpose: request.stub.purpose,
|
|
1227
|
+
kind: request.stub.kind
|
|
1228
|
+
});
|
|
1229
|
+
} catch {
|
|
1230
|
+
return fail2(
|
|
1231
|
+
503,
|
|
1232
|
+
"server-error",
|
|
1233
|
+
"we could not check this just now \u2014 try again shortly"
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
if (answer?.verdict === "not-declared") {
|
|
1237
|
+
return fail2(
|
|
1238
|
+
409,
|
|
1239
|
+
"purpose-not-declared",
|
|
1240
|
+
`this site does not declare ${request.stub.purpose ?? "that purpose"} \u2014 declare it on Developer Sites, and the people who have already connected will each map the new slot before it routes`
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
if (answer?.verdict === "unmapped") {
|
|
1244
|
+
return fail2(
|
|
1245
|
+
409,
|
|
1246
|
+
"slot-unsatisfiable",
|
|
1247
|
+
"nobody has chosen what answers this yet"
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
if (answer?.verdict === "waiting") {
|
|
1251
|
+
return fail2(
|
|
1252
|
+
409,
|
|
1253
|
+
"slot-waiting",
|
|
1254
|
+
"nothing can answer this right now \u2014 try again later"
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
const job = await this.#deps.state.enqueue({
|
|
649
1258
|
id: request.stub.id,
|
|
650
1259
|
siteId,
|
|
651
1260
|
stub: request.stub
|
|
652
1261
|
});
|
|
1262
|
+
if ("refused" in job) {
|
|
1263
|
+
return fail2(400, "bad-request", "that stub was not accepted");
|
|
1264
|
+
}
|
|
653
1265
|
return ok2({ jobId: job.id, state: job.state });
|
|
654
1266
|
}
|
|
655
1267
|
);
|
|
@@ -668,9 +1280,9 @@ var SitePlane = class {
|
|
|
668
1280
|
{ siteId },
|
|
669
1281
|
QueryRequest,
|
|
670
1282
|
(request) => request.siteId,
|
|
671
|
-
(_request, site) => {
|
|
672
|
-
this.#deps.state.sweep(
|
|
673
|
-
const jobs = this.#deps.state.awaiting(site).map((job) => ({
|
|
1283
|
+
async (_request, site) => {
|
|
1284
|
+
await this.#deps.state.sweep();
|
|
1285
|
+
const jobs = (await this.#deps.state.awaiting(site)).map((job) => ({
|
|
674
1286
|
jobId: job.id,
|
|
675
1287
|
// Non-null by construction: `awaiting` only returns claimed jobs.
|
|
676
1288
|
// The optional chain is here so a future state-machine edit that
|
|
@@ -680,34 +1292,66 @@ var SitePlane = class {
|
|
|
680
1292
|
runnerId: job.claimedBy?.runnerId,
|
|
681
1293
|
leaseId: job.claimedBy?.leaseId,
|
|
682
1294
|
/** So a site can decline to seal for a claim about to expire. */
|
|
683
|
-
awaitingUntil: job.awaitingUntil
|
|
1295
|
+
awaitingUntil: job.awaitingUntil,
|
|
1296
|
+
/**
|
|
1297
|
+
* When the *grant* ends — cloud_008 §0.6.
|
|
1298
|
+
*
|
|
1299
|
+
* Distinct from `awaitingUntil`, which bounds how long this relay
|
|
1300
|
+
* waits for the site to seal. A site adopting the lease into its own
|
|
1301
|
+
* records needs the lease's clock; given the other one it recorded a
|
|
1302
|
+
* grant that expired in seconds, then refused the device's own
|
|
1303
|
+
* result for want of a matching lease.
|
|
1304
|
+
*/
|
|
1305
|
+
leaseExpiresAt: job.claimedBy?.leaseExpiresAt
|
|
684
1306
|
}));
|
|
685
1307
|
return ok2({ jobs });
|
|
686
1308
|
}
|
|
687
1309
|
);
|
|
688
1310
|
}
|
|
1311
|
+
/**
|
|
1312
|
+
* The site withdraws a job — cloud_008 §2.2.
|
|
1313
|
+
*
|
|
1314
|
+
* Signed and site-scoped like every other site-plane call. A cancellation
|
|
1315
|
+
* is not a delete: a device already running the job has to be told, and it
|
|
1316
|
+
* hears at its next heartbeat.
|
|
1317
|
+
*/
|
|
1318
|
+
cancel(auth, body) {
|
|
1319
|
+
return this.#authed(
|
|
1320
|
+
auth,
|
|
1321
|
+
body,
|
|
1322
|
+
CancelRequest,
|
|
1323
|
+
(request) => request.siteId,
|
|
1324
|
+
async (request, siteId) => {
|
|
1325
|
+
const cancelled = await this.#deps.state.cancel({
|
|
1326
|
+
jobId: request.jobId,
|
|
1327
|
+
siteId
|
|
1328
|
+
});
|
|
1329
|
+
return ok2({ cancelled });
|
|
1330
|
+
}
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
689
1333
|
payload(auth, body) {
|
|
690
1334
|
return this.#authed(
|
|
691
1335
|
auth,
|
|
692
1336
|
body,
|
|
693
1337
|
PayloadRequest,
|
|
694
1338
|
(request) => request.siteId,
|
|
695
|
-
(request, siteId) => {
|
|
696
|
-
const
|
|
697
|
-
if (
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1339
|
+
async (request, siteId) => {
|
|
1340
|
+
const bytes = envelopeBytes2(request.envelope);
|
|
1341
|
+
if (bytes > MAX_ENVELOPE_BYTES3) return tooLargeRefusal(bytes);
|
|
1342
|
+
const sealed = await this.#deps.state.seal({
|
|
1343
|
+
jobId: request.jobId,
|
|
1344
|
+
siteId,
|
|
1345
|
+
envelope: request.envelope
|
|
1346
|
+
});
|
|
1347
|
+
if ("refused" in sealed) {
|
|
1348
|
+
return sealed.refused === "not-found" ? fail2(404, "not-found", "unknown job") : fail2(
|
|
702
1349
|
409,
|
|
703
1350
|
"too-late",
|
|
704
|
-
`job is ${
|
|
1351
|
+
`job is ${sealed.was ?? "gone"}, not awaiting payload`
|
|
705
1352
|
);
|
|
706
1353
|
}
|
|
707
|
-
|
|
708
|
-
job.state = "ready";
|
|
709
|
-
delete job.awaitingUntil;
|
|
710
|
-
return ok2({ jobId: job.id, state: job.state });
|
|
1354
|
+
return ok2({ jobId: request.jobId, state: sealed.state });
|
|
711
1355
|
}
|
|
712
1356
|
);
|
|
713
1357
|
}
|
|
@@ -718,8 +1362,8 @@ var SitePlane = class {
|
|
|
718
1362
|
{ siteId },
|
|
719
1363
|
QueryRequest,
|
|
720
1364
|
(request) => request.siteId,
|
|
721
|
-
(_request, site) => {
|
|
722
|
-
const jobs = this.#deps.state.finished(site).map((job) => ({
|
|
1365
|
+
async (_request, site) => {
|
|
1366
|
+
const jobs = (await this.#deps.state.finished(site)).map((job) => ({
|
|
723
1367
|
jobId: job.id,
|
|
724
1368
|
envelope: job.result,
|
|
725
1369
|
disposition: job.disposition,
|
|
@@ -728,10 +1372,22 @@ var SitePlane = class {
|
|
|
728
1372
|
leaseId: job.claimedBy?.leaseId,
|
|
729
1373
|
/**
|
|
730
1374
|
* Which device ran it, so the site can verify the signature against
|
|
731
|
-
* the key it was told to seal to — and so `
|
|
1375
|
+
* the key it was told to seal to — and so `PROVENANCE_NAMES_DEVICE` can
|
|
732
1376
|
* name a foreign device rather than guessing (cloud_004 §11.2).
|
|
733
1377
|
*/
|
|
734
|
-
device: job.claimedBy?.device
|
|
1378
|
+
device: job.claimedBy?.device,
|
|
1379
|
+
/**
|
|
1380
|
+
* Whose machine ran it — cloud_008 §2.5, finding 41.
|
|
1381
|
+
*
|
|
1382
|
+
* The relay has held this since the claim: `claimedBy.owner` is the
|
|
1383
|
+
* owner id the *projection* supplied, in the same namespace the
|
|
1384
|
+
* direct plane's `runnerOwner` uses. The cloud lane was filling that
|
|
1385
|
+
* field with `keyId(device.identity)` instead — a key id where every
|
|
1386
|
+
* other plane puts a user id, so an app comparing provenance across
|
|
1387
|
+
* lanes compared two namespaces for equality and got `false` for
|
|
1388
|
+
* the same person.
|
|
1389
|
+
*/
|
|
1390
|
+
runnerOwner: job.claimedBy?.owner
|
|
735
1391
|
}));
|
|
736
1392
|
return ok2({ jobs });
|
|
737
1393
|
}
|
|
@@ -747,23 +1403,38 @@ var Relay = class {
|
|
|
747
1403
|
#site;
|
|
748
1404
|
#now;
|
|
749
1405
|
#basePath;
|
|
1406
|
+
#debug;
|
|
750
1407
|
constructor(options) {
|
|
751
|
-
this.state = new RelayState(
|
|
1408
|
+
this.state = options.store ?? new RelayState({
|
|
1409
|
+
now: options.now ?? Date.now,
|
|
1410
|
+
...options.onUnsealedChurn === void 0 ? {} : { onUnsealedChurn: options.onUnsealedChurn }
|
|
1411
|
+
});
|
|
752
1412
|
this.projection = new Projection(options.fixture);
|
|
753
1413
|
this.#now = options.now ?? Date.now;
|
|
754
1414
|
this.#basePath = (options.basePath ?? "/byollm").replace(/\/+$/, "");
|
|
1415
|
+
this.#debug = options.debug ?? false;
|
|
755
1416
|
this.#daemon = new DaemonPlane({
|
|
756
1417
|
state: this.state,
|
|
757
1418
|
projection: this.projection,
|
|
758
1419
|
now: this.#now,
|
|
759
1420
|
leaseMs: options.leaseMs ?? 6e4,
|
|
760
|
-
|
|
1421
|
+
pairingCodes: options.pairingCodes ?? new MemoryPairingCodes(() => this.#now()),
|
|
1422
|
+
...options.verificationUrl === void 0 ? {} : { verificationUrl: options.verificationUrl },
|
|
1423
|
+
...options.controlPlanePublic === void 0 ? {} : { controlPlanePublic: options.controlPlanePublic },
|
|
1424
|
+
...options.authorGrant === void 0 ? {} : { authorGrant: options.authorGrant },
|
|
1425
|
+
...options.updateOffer === void 0 ? {} : { updateOffer: options.updateOffer },
|
|
1426
|
+
...options.daemonFloor === void 0 ? {} : { daemonFloor: options.daemonFloor }
|
|
761
1427
|
});
|
|
1428
|
+
if (options.controlPlanePublic !== void 0 && options.authorGrant === void 0) {
|
|
1429
|
+
throw new Error(
|
|
1430
|
+
"controlPlanePublic is set but authorGrant is not: devices would be told to expect signed grants that nothing here can produce, and would refuse every job"
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
762
1433
|
this.#site = new SitePlane({
|
|
763
1434
|
state: this.state,
|
|
764
1435
|
projection: this.projection,
|
|
765
1436
|
now: this.#now,
|
|
766
|
-
|
|
1437
|
+
...options.satisfiable === void 0 ? {} : { satisfiable: options.satisfiable }
|
|
767
1438
|
});
|
|
768
1439
|
}
|
|
769
1440
|
/** Replace the projection — a control-plane push, or a fixture edit. */
|
|
@@ -779,17 +1450,35 @@ var Relay = class {
|
|
|
779
1450
|
* site vanished should return to the queue without waiting for someone to
|
|
780
1451
|
* ask about it.
|
|
781
1452
|
*/
|
|
782
|
-
sweep() {
|
|
783
|
-
|
|
1453
|
+
async sweep() {
|
|
1454
|
+
const requeued = await this.state.sweep();
|
|
1455
|
+
return { requeued: requeued.map((j) => j.id) };
|
|
784
1456
|
}
|
|
785
1457
|
/** The whole HTTP surface. */
|
|
786
1458
|
async handle(request) {
|
|
787
1459
|
const url = new URL(request.url);
|
|
788
1460
|
const path = url.pathname;
|
|
789
1461
|
if (path === "/debug" || path === `${this.#basePath}/debug`) {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1462
|
+
const siteId = url.searchParams.get("site");
|
|
1463
|
+
if (!this.#debug) {
|
|
1464
|
+
return new Response(JSON.stringify({ error: "not-found" }), {
|
|
1465
|
+
status: 404,
|
|
1466
|
+
headers: { "content-type": "application/json" }
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
if (siteId === null || this.projection.siteFor(siteId) === null) {
|
|
1470
|
+
return new Response(JSON.stringify({ error: "bad-request" }), {
|
|
1471
|
+
status: 400,
|
|
1472
|
+
headers: { "content-type": "application/json" }
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
return new Response(
|
|
1476
|
+
await debugPage(this.state, this.#now(), {
|
|
1477
|
+
siteId,
|
|
1478
|
+
consents: (owner) => this.projection.consentFor(owner, siteId) !== null
|
|
1479
|
+
}),
|
|
1480
|
+
{ headers: { "content-type": "text/html; charset=utf-8" } }
|
|
1481
|
+
);
|
|
793
1482
|
}
|
|
794
1483
|
const rawBody = request.method === "POST" ? await request.text() : "";
|
|
795
1484
|
const body = rawBody === "" ? void 0 : safeJson(rawBody);
|
|
@@ -799,25 +1488,40 @@ var Relay = class {
|
|
|
799
1488
|
rawBody,
|
|
800
1489
|
signature: signatureFrom(request.headers, "x-byollm-runner")
|
|
801
1490
|
};
|
|
1491
|
+
if (path.startsWith("/byollm/") || path.startsWith("/relay/")) {
|
|
1492
|
+
const refusal = checkProtocolVersion({
|
|
1493
|
+
protocolVersion: declaredVersion({ body, query: url.searchParams })
|
|
1494
|
+
});
|
|
1495
|
+
if (refusal) return json({ status: 400, body: refusal });
|
|
1496
|
+
}
|
|
802
1497
|
const siteAuth = {
|
|
803
1498
|
endpoint,
|
|
804
1499
|
rawBody,
|
|
805
1500
|
signature: signatureFrom(request.headers, "x-byollm-site")
|
|
806
1501
|
};
|
|
807
1502
|
if (path === "/relay/site/enqueue") {
|
|
808
|
-
return json(this.#site.enqueue(siteAuth, body));
|
|
1503
|
+
return json(await this.#site.enqueue(siteAuth, body));
|
|
809
1504
|
}
|
|
810
1505
|
if (path === "/relay/site/payload") {
|
|
811
|
-
return json(this.#site.payload(siteAuth, body));
|
|
1506
|
+
return json(await this.#site.payload(siteAuth, body));
|
|
1507
|
+
}
|
|
1508
|
+
if (path === "/relay/site/cancel") {
|
|
1509
|
+
return json(await this.#site.cancel(siteAuth, body));
|
|
812
1510
|
}
|
|
813
1511
|
if (path === "/relay/site/pending") {
|
|
814
1512
|
return json(
|
|
815
|
-
this.#site.pending(
|
|
1513
|
+
await this.#site.pending(
|
|
1514
|
+
siteAuth,
|
|
1515
|
+
url.searchParams.get("siteId") ?? ""
|
|
1516
|
+
)
|
|
816
1517
|
);
|
|
817
1518
|
}
|
|
818
1519
|
if (path === "/relay/site/results") {
|
|
819
1520
|
return json(
|
|
820
|
-
this.#site.results(
|
|
1521
|
+
await this.#site.results(
|
|
1522
|
+
siteAuth,
|
|
1523
|
+
url.searchParams.get("siteId") ?? ""
|
|
1524
|
+
)
|
|
821
1525
|
);
|
|
822
1526
|
}
|
|
823
1527
|
if (!path.startsWith(`${this.#basePath}/`)) {
|
|
@@ -825,17 +1529,17 @@ var Relay = class {
|
|
|
825
1529
|
}
|
|
826
1530
|
switch (auth.endpoint) {
|
|
827
1531
|
case "pair":
|
|
828
|
-
return json(this.#daemon.pair(body));
|
|
1532
|
+
return json(await this.#daemon.pair(body));
|
|
829
1533
|
case "claim":
|
|
830
|
-
return json(this.#daemon.claim(auth, body));
|
|
1534
|
+
return json(await this.#daemon.claim(auth, body));
|
|
831
1535
|
case "fetch":
|
|
832
|
-
return json(this.#daemon.fetch(auth, body));
|
|
1536
|
+
return json(await this.#daemon.fetch(auth, body));
|
|
833
1537
|
case "result":
|
|
834
|
-
return json(this.#daemon.result(auth, body));
|
|
1538
|
+
return json(await this.#daemon.result(auth, body));
|
|
835
1539
|
case "heartbeat":
|
|
836
|
-
return json(this.#daemon.heartbeat(auth, body));
|
|
1540
|
+
return json(await this.#daemon.heartbeat(auth, body));
|
|
837
1541
|
case "release":
|
|
838
|
-
return json(this.#daemon.release(auth, body));
|
|
1542
|
+
return json(await this.#daemon.release(auth, body));
|
|
839
1543
|
default:
|
|
840
1544
|
return json({ status: 404, body: { error: "not-found" } });
|
|
841
1545
|
}
|
|
@@ -863,16 +1567,28 @@ var json = (result) => new Response(JSON.stringify(result.body), {
|
|
|
863
1567
|
});
|
|
864
1568
|
export {
|
|
865
1569
|
AWAITING_PAYLOAD_MS,
|
|
1570
|
+
CHURN_EXPIRIES,
|
|
1571
|
+
CHURN_QUIET_MS,
|
|
1572
|
+
CHURN_WINDOW_MS,
|
|
866
1573
|
ConsentRecord,
|
|
867
1574
|
DeviceRecord,
|
|
868
1575
|
EMPTY_FIXTURE,
|
|
1576
|
+
MAX_OUTSTANDING_PAIRINGS,
|
|
1577
|
+
MemoryPairingCodes,
|
|
1578
|
+
PAIRING_BUSY_MESSAGE,
|
|
1579
|
+
PAIRING_CODE_TTL_MS,
|
|
869
1580
|
Projection,
|
|
870
1581
|
Relay,
|
|
871
1582
|
RelayFixture as RelayFixtureSchema,
|
|
872
1583
|
RelayState,
|
|
873
1584
|
RevocationRecord,
|
|
874
1585
|
RosterRecord,
|
|
1586
|
+
SEAL_ATTEMPTS_BEFORE_EVICTION,
|
|
875
1587
|
SiteRecord,
|
|
876
|
-
|
|
1588
|
+
UNSEALED_PER_PAIR,
|
|
1589
|
+
debugPage,
|
|
1590
|
+
newDeviceCode,
|
|
1591
|
+
newUserCode,
|
|
1592
|
+
routeKey
|
|
877
1593
|
};
|
|
878
1594
|
//# sourceMappingURL=index.js.map
|