@frockbot/plugin-user-machine 0.0.0 → 0.1.0

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/src/user.ts ADDED
@@ -0,0 +1,442 @@
1
+ // The User backend Contribution: the machine registry's authority.
2
+ //
3
+ // It is mounted into the User Durable Object's Cordis root beside Settings,
4
+ // Credentials, Flock and the rest, and it owns four things and no more — the
5
+ // registry rows, the pairing offers, the command queue with its leases, and
6
+ // the results. "The User's Durable Object is the authority for everything
7
+ // User-scoped", and a machine is a User asset: a Bot reaches one only through
8
+ // a Capability under an Assignment, and (from R3) a per-call human approval.
9
+ //
10
+ // Three seams it does not own:
11
+ //
12
+ // * **The secret.** `MACHINE_TOKEN_SECRET` is read from the host, never
13
+ // stored, and used only to mint. The token is handed back exactly once, on
14
+ // the enrollment response; what stays here is `SHA-256(token)`.
15
+ // * **The clock.** Injected, so presence arithmetic and lease expiry are
16
+ // testable without waiting ninety seconds.
17
+ // * **The transport.** Nothing here is an HTTP response. The gateway
18
+ // Contribution turns these answers into one.
19
+ //
20
+ // The long poll is the one place this object holds something in memory: a set
21
+ // of waiters, so an enqueue can cut a hold short. That memory is a latency
22
+ // optimisation and never a fact — an evicted object simply drops the hold, the
23
+ // agent's request fails, and its next poll finds the same queue. "Client
24
+ // disconnect detaches an observer."
25
+
26
+ import {
27
+ MACHINE_LIMITS_V1,
28
+ MachineTokenError,
29
+ decodeMachineEnrollmentV1,
30
+ machineListEntryV1,
31
+ machineTokenDigestV1,
32
+ machineTokenMatchesRecordV1,
33
+ mintMachineTokenV1,
34
+ type MachineClaimReceiptV1,
35
+ type MachineCommandResultV1,
36
+ type MachineEnrollmentReceiptV1,
37
+ type MachineListViewV1,
38
+ type MachinePairingOfferV1,
39
+ type MachinePollResultV1,
40
+ type MachineRecordV1,
41
+ type MachineResultReceiptV1,
42
+ type MachineTokenClaimsV1,
43
+ } from "@frockbot/machine-protocol";
44
+ import type { Plugin } from "cordis";
45
+ import {
46
+ machinePairingCodeDigestV1,
47
+ machinePairingNonceV1,
48
+ mintMachinePairingCodeV1,
49
+ type MachinePairingClaimsV1,
50
+ } from "./pairing.js";
51
+ import {
52
+ decodeMachineResultDeliveryV1,
53
+ machineResultDeliveryV1,
54
+ type MachineResultDeliveryV1,
55
+ } from "./delivery.js";
56
+ import {
57
+ MACHINE_DELIVERY_PREFIX,
58
+ machineDeliveryKeyV1,
59
+ } from "./storage-keys.js";
60
+ import type { MachineTargetViewV1 } from "./target.js";
61
+ import {
62
+ claimMachineCommandV1,
63
+ dispatchMachineCommandV1,
64
+ enrollMachineV1,
65
+ listMachineRecordsV1,
66
+ machineListViewV1,
67
+ machineQuotaSnapshotV1,
68
+ pendingMachineCommandsV1,
69
+ readMachineRecordV1,
70
+ readMachineResultV1,
71
+ recordMachineResultV1,
72
+ revokeMachineV1,
73
+ sweepMachineLeasesV1,
74
+ touchMachineV1,
75
+ writeMachinePairingV1,
76
+ MachineRegistryError,
77
+ type MachineDispatchOutcomeV1,
78
+ type MachineStorageV1,
79
+ } from "./store.js";
80
+
81
+ export interface MachineUserBackendHost {
82
+ /** The User Durable Object's own storage. */
83
+ storage: MachineStorageV1;
84
+ /**
85
+ * The deployment secret every machine token and pairing code is signed with.
86
+ * Absent closes the door: a pairing is refused rather than offered under a
87
+ * signature nothing could verify.
88
+ */
89
+ readSecret(name: "MACHINE_TOKEN_SECRET"): string | undefined;
90
+ /** Injected so presence and leases are testable without real time. */
91
+ now?(): number;
92
+ /** Injected so a test can drive a hold without waiting twenty-five seconds. */
93
+ sleep?(ms: number): Promise<void>;
94
+ }
95
+
96
+ const defaultSleep = (ms: number): Promise<void> =>
97
+ new Promise((resolve) => setTimeout(resolve, ms));
98
+
99
+ export class MachineUserBackendContribution {
100
+ readonly packageId = "user-machine";
101
+ /** One set of waiting long polls per machine. Memory, never a fact. */
102
+ private readonly waiting = new Map<string, Set<() => void>>();
103
+
104
+ constructor(private readonly host: MachineUserBackendHost) {}
105
+
106
+ private now(): number {
107
+ return this.host.now?.() ?? Date.now();
108
+ }
109
+
110
+ private secret(): string {
111
+ const secret = this.host.readSecret("MACHINE_TOKEN_SECRET");
112
+ if (!secret) {
113
+ throw new MachineRegistryError(
114
+ 503,
115
+ "machine registration is not configured for this deployment",
116
+ );
117
+ }
118
+ return secret;
119
+ }
120
+
121
+ /**
122
+ * The machine a presented token is for, or a refusal.
123
+ *
124
+ * The edge already proved the token was minted here. This is the second
125
+ * check, and the authoritative one: the digest must be this machine's, at
126
+ * this key version, and the machine must not be revoked. Revocation bumps
127
+ * the key version, so every token issued before it dies here.
128
+ */
129
+ private async authorize(
130
+ claims: MachineTokenClaimsV1,
131
+ presentedDigest: string,
132
+ machineId: string,
133
+ ): Promise<MachineRecordV1> {
134
+ if (claims.m !== machineId) {
135
+ throw new MachineTokenError(401, "machine token is invalid");
136
+ }
137
+ const record = await readMachineRecordV1(this.host.storage, machineId);
138
+ if (
139
+ !record ||
140
+ !machineTokenMatchesRecordV1(record, claims, presentedDigest)
141
+ ) {
142
+ throw new MachineTokenError(401, "machine token is invalid");
143
+ }
144
+ return record;
145
+ }
146
+
147
+ /**
148
+ * A pairing offer, from the authenticated settings surface.
149
+ *
150
+ * The browser is handed the code and the machine id it names; the backend
151
+ * keeps only the digest. Five minutes, one use.
152
+ */
153
+ async createPairing(
154
+ userId: string,
155
+ request: { label?: string } = {},
156
+ ): Promise<MachinePairingOfferV1> {
157
+ const secret = this.secret();
158
+ const now = this.now();
159
+ const registered = await listMachineRecordsV1(this.host.storage);
160
+ if (
161
+ registered.filter((record) => record.revokedAt === undefined).length >=
162
+ MACHINE_LIMITS_V1.maxMachinesPerUser
163
+ ) {
164
+ throw new MachineRegistryError(
165
+ 429,
166
+ `Refused: this account holds ${MACHINE_LIMITS_V1.maxMachinesPerUser} registered machines, which is the quota.`,
167
+ );
168
+ }
169
+ const machineId = crypto.randomUUID();
170
+ const code = await mintMachinePairingCodeV1(secret, {
171
+ userId,
172
+ machineId,
173
+ nonce: machinePairingNonceV1(),
174
+ });
175
+ const record = await writeMachinePairingV1(this.host.storage, {
176
+ userId,
177
+ machineId,
178
+ ...(request.label === undefined ? {} : { label: request.label }),
179
+ codeDigest: await machinePairingCodeDigestV1(code),
180
+ now,
181
+ });
182
+ return {
183
+ schemaVersion: 1,
184
+ code,
185
+ machineId,
186
+ expiresAt: record.expiresAt,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Enrollment. The offer is spent, the row is written, and the token exists
192
+ * outside this object exactly once — in the response.
193
+ */
194
+ async enroll(
195
+ claims: MachinePairingClaimsV1,
196
+ input: unknown,
197
+ ): Promise<MachineEnrollmentReceiptV1> {
198
+ const secret = this.secret();
199
+ const enrollment = decodeMachineEnrollmentV1(input);
200
+ const token = await mintMachineTokenV1(secret, {
201
+ u: claims.userId,
202
+ m: claims.machineId,
203
+ v: 1,
204
+ });
205
+ const record = await enrollMachineV1(this.host.storage, {
206
+ userId: claims.userId,
207
+ machineId: claims.machineId,
208
+ enrollment,
209
+ codeDigest: await machinePairingCodeDigestV1(enrollment.code),
210
+ tokenDigest: await machineTokenDigestV1(token),
211
+ now: this.now(),
212
+ });
213
+ return {
214
+ schemaVersion: 1,
215
+ machineId: record.machineId,
216
+ token,
217
+ keyVersion: record.keyVersion,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * One bounded long poll.
223
+ *
224
+ * Presence is refreshed first, so a machine that is holding a poll is
225
+ * connected for as long as it holds it. The hold ends on the first of: a
226
+ * command being queued, the wait elapsing, or the object being evicted —
227
+ * and the last of those costs nothing, because the queue is durable and the
228
+ * agent polls again.
229
+ */
230
+ async poll(
231
+ claims: MachineTokenClaimsV1,
232
+ tokenDigest: string,
233
+ machineId: string,
234
+ waitSeconds: number,
235
+ ): Promise<MachinePollResultV1> {
236
+ await this.authorize(claims, tokenDigest, machineId);
237
+ await touchMachineV1(this.host.storage, machineId, this.now());
238
+ await sweepMachineLeasesV1(this.host.storage, machineId, this.now());
239
+ let commands = await pendingMachineCommandsV1(this.host.storage, machineId);
240
+ const wait = Math.min(
241
+ Math.max(waitSeconds, 0),
242
+ MACHINE_LIMITS_V1.pollMaxWaitSeconds,
243
+ );
244
+ if (commands.length === 0 && wait > 0) {
245
+ await this.hold(machineId, wait * 1_000);
246
+ commands = await pendingMachineCommandsV1(this.host.storage, machineId);
247
+ }
248
+ return {
249
+ schemaVersion: 1,
250
+ commands,
251
+ serverTime: new Date(this.now()).toISOString(),
252
+ };
253
+ }
254
+
255
+ private async hold(machineId: string, ms: number): Promise<void> {
256
+ const sleep = this.host.sleep ?? defaultSleep;
257
+ let wake: (() => void) | undefined;
258
+ const waiters = this.waiting.get(machineId) ?? new Set<() => void>();
259
+ this.waiting.set(machineId, waiters);
260
+ const woken = new Promise<void>((resolve) => {
261
+ wake = resolve;
262
+ waiters.add(resolve);
263
+ });
264
+ try {
265
+ await Promise.race([sleep(ms), woken]);
266
+ } finally {
267
+ if (wake) waiters.delete(wake);
268
+ if (waiters.size === 0) this.waiting.delete(machineId);
269
+ }
270
+ }
271
+
272
+ private notify(machineId: string): void {
273
+ const waiters = this.waiting.get(machineId);
274
+ if (!waiters) return;
275
+ for (const wake of [...waiters]) wake();
276
+ }
277
+
278
+ async claim(
279
+ claims: MachineTokenClaimsV1,
280
+ tokenDigest: string,
281
+ machineId: string,
282
+ commandId: string,
283
+ ): Promise<MachineClaimReceiptV1> {
284
+ await this.authorize(claims, tokenDigest, machineId);
285
+ const now = this.now();
286
+ await touchMachineV1(this.host.storage, machineId, now);
287
+ await sweepMachineLeasesV1(this.host.storage, machineId, now);
288
+ return claimMachineCommandV1(this.host.storage, machineId, commandId, now);
289
+ }
290
+
291
+ async recordResult(
292
+ claims: MachineTokenClaimsV1,
293
+ tokenDigest: string,
294
+ machineId: string,
295
+ commandId: string,
296
+ input: unknown,
297
+ ): Promise<MachineResultReceiptV1> {
298
+ await this.authorize(claims, tokenDigest, machineId);
299
+ const now = this.now();
300
+ await touchMachineV1(this.host.storage, machineId, now);
301
+ const decoded = input as { commandId?: unknown };
302
+ if (
303
+ typeof decoded?.commandId === "string" &&
304
+ decoded.commandId !== commandId
305
+ ) {
306
+ throw new MachineRegistryError(
307
+ 400,
308
+ "machine result does not match the request path",
309
+ );
310
+ }
311
+ const { receipt, result, command } = await recordMachineResultV1(
312
+ this.host.storage,
313
+ machineId,
314
+ input,
315
+ now,
316
+ );
317
+ // Only the write that recorded it is delivered. A replayed POST answers
318
+ // `replayed` and tells nobody a second time — "recovery never silently
319
+ // duplicates" applied to a laptop that retried.
320
+ if (receipt.status === "recorded" && command) {
321
+ await this.host.storage.put(
322
+ machineDeliveryKeyV1(result.commandId),
323
+ machineResultDeliveryV1(command, result),
324
+ );
325
+ }
326
+ return receipt;
327
+ }
328
+
329
+ /** The `ListMachines` projection, and what the settings section renders. */
330
+ async list(): Promise<MachineListViewV1> {
331
+ return machineListViewV1(
332
+ await listMachineRecordsV1(this.host.storage),
333
+ this.now(),
334
+ );
335
+ }
336
+
337
+ async revoke(machineId: string): Promise<MachineListViewV1> {
338
+ await revokeMachineV1(this.host.storage, machineId, this.now());
339
+ // A revoked machine's waiting poll is woken so it stops holding a request
340
+ // it will never be answered on; its next poll is a 401.
341
+ this.notify(machineId);
342
+ return this.list();
343
+ }
344
+
345
+ /**
346
+ * Put one approved command on a machine's queue.
347
+ *
348
+ * R3's approval settlement is the caller that matters. It is here in R2
349
+ * because the queue, its quota and its idempotency are this object's rules,
350
+ * and the stub agent has to have something to poll for.
351
+ */
352
+ async dispatch(command: unknown): Promise<MachineDispatchOutcomeV1> {
353
+ const outcome = await dispatchMachineCommandV1(
354
+ this.host.storage,
355
+ command,
356
+ this.now(),
357
+ );
358
+ if (outcome.status === "queued") this.notify(outcome.command.machineId);
359
+ return outcome;
360
+ }
361
+
362
+ /**
363
+ * Take every finished command waiting to be told to a Bot.
364
+ *
365
+ * Drained by the Worker that just answered the machine, because the Bot
366
+ * Durable Object namespace is the adapter's and a Durable Object that holds
367
+ * a reference to another one cannot be evicted while it does. Taking is
368
+ * removing: at most once, and losing one costs a preamble line and no
369
+ * durable fact, since the result itself stays readable.
370
+ */
371
+ async takeDeliveries(): Promise<MachineResultDeliveryV1[]> {
372
+ const stored = await this.host.storage.list<unknown>({
373
+ prefix: MACHINE_DELIVERY_PREFIX,
374
+ });
375
+ const taken: MachineResultDeliveryV1[] = [];
376
+ for (const [key, value] of stored) {
377
+ await this.host.storage.delete(key);
378
+ try {
379
+ taken.push(decodeMachineResultDeliveryV1(value, "machine delivery"));
380
+ } catch {
381
+ // A record this Package cannot read is dropped rather than kept
382
+ // forever: the result it points at is still the durable answer.
383
+ }
384
+ }
385
+ return taken;
386
+ }
387
+
388
+ /** The full result of one command, read on demand rather than pushed. */
389
+ async readResult(
390
+ commandId: string,
391
+ ): Promise<MachineCommandResultV1 | undefined> {
392
+ return readMachineResultV1(this.host.storage, commandId);
393
+ }
394
+
395
+ /** One registry row, for a caller that already knows which machine it wants. */
396
+ async readMachine(machineId: string): Promise<MachineRecordV1 | undefined> {
397
+ return readMachineRecordV1(this.host.storage, machineId);
398
+ }
399
+
400
+ /**
401
+ * One machine and the counters a control tool checks its quota against, in
402
+ * one read.
403
+ *
404
+ * A tool has five things to establish before it may ask a person anything,
405
+ * and resolving them one at a time would be four round trips answering
406
+ * against four different instants.
407
+ */
408
+ async describeTarget(machineId: string): Promise<MachineTargetViewV1> {
409
+ const now = this.now();
410
+ const record = await readMachineRecordV1(this.host.storage, machineId);
411
+ const counters = await machineQuotaSnapshotV1(
412
+ this.host.storage,
413
+ machineId,
414
+ now,
415
+ );
416
+ return {
417
+ schemaVersion: 1,
418
+ machineId,
419
+ ...(record === undefined
420
+ ? {}
421
+ : { entry: machineListEntryV1(record, now) }),
422
+ queuedCommands: counters.queuedCommands,
423
+ commandsToday: counters.commandsToday,
424
+ serverTime: new Date(now).toISOString(),
425
+ };
426
+ }
427
+
428
+ /** Presence, as the tool and the settings section see it. */
429
+ async describe(machineId: string) {
430
+ const record = await readMachineRecordV1(this.host.storage, machineId);
431
+ return record === undefined
432
+ ? undefined
433
+ : machineListEntryV1(record, this.now());
434
+ }
435
+ }
436
+
437
+ export function createMachineUserBackendPlugin(
438
+ host: MachineUserBackendHost,
439
+ lifecycle: { mount(value: MachineUserBackendContribution): () => void },
440
+ ): Plugin {
441
+ return () => lifecycle.mount(new MachineUserBackendContribution(host));
442
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
12
+ "types": ["bun", "vite/client"]
13
+ },
14
+ "include": ["src/**/*.ts", "src/**/*.vue"]
15
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/plugin-user-machine
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.