@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/backend.ts ADDED
@@ -0,0 +1,370 @@
1
+ // The registered-machine gateway Contribution: seven routes, on two doors.
2
+ //
3
+ // Three are ordinary authenticated routes beside `/api/settings` — the browser
4
+ // asks for a pairing code, reads the registry, and revokes a machine:
5
+ //
6
+ // POST /api/machines/pair mint a one-time, five-minute code
7
+ // GET /api/machines the `ListMachines` projection
8
+ // POST /api/machines/:id/revoke kill every token this machine holds
9
+ //
10
+ // Four are not authenticated at all, because the caller is a program on
11
+ // somebody's laptop and has no session:
12
+ //
13
+ // POST /api/machines/enroll bearer: pairing code
14
+ // GET /api/machines/:id/poll?wait=25 bearer: machine token
15
+ // POST /api/machines/:id/commands/:commandId/claim bearer: machine token
16
+ // POST /api/machines/:id/commands/:commandId/result bearer: machine token
17
+ //
18
+ // Those four are `publicRoute`s: they run at the seam in
19
+ // `apps/cloudflare/src/gateway.ts` that executes *before* session
20
+ // authentication, exactly where `plugin-routines`' webhook runs. Public means
21
+ // "no session", never "no authority" — and the order of the checks is the
22
+ // whole design, port for port from that webhook:
23
+ //
24
+ // 1. The presented bearer is verified against the deployment secret, in
25
+ // constant time. The gateway is stateless: without claims it could not
26
+ // address a Durable Object at all without creating one on an anonymous
27
+ // caller's word.
28
+ // 2. Only then is the object addressed, and only with the claims a token that
29
+ // was minted here carries. Inside, the digest is checked against the
30
+ // machine record — the authority — so revocation is effective on the very
31
+ // next call.
32
+ //
33
+ // Nothing here holds state, and nothing here decides who owns a machine. The
34
+ // User Durable Object refuses any RPC naming a User it is not.
35
+
36
+ import {
37
+ MACHINE_POLL_WAIT_PARAM_V1,
38
+ MACHINE_ROUTE_PREFIX_V1,
39
+ MachineDecodeError,
40
+ MachineTokenError,
41
+ decodeMachineClaimReceiptV1,
42
+ decodeMachineEnrollmentReceiptV1,
43
+ decodeMachineIdV1,
44
+ decodeMachineListViewV1,
45
+ decodeMachinePairingOfferV1,
46
+ decodeMachinePairingRequestV1,
47
+ decodeMachinePollResultV1,
48
+ decodeMachinePollWaitV1,
49
+ decodeMachineResultReceiptV1,
50
+ machineBearerTokenV1,
51
+ machineTokenDigestV1,
52
+ verifyMachineTokenV1,
53
+ type MachineClaimReceiptV1,
54
+ type MachineEnrollmentReceiptV1,
55
+ type MachineListViewV1,
56
+ type MachinePairingOfferV1,
57
+ type MachinePollResultV1,
58
+ type MachineResultReceiptV1,
59
+ type MachineTokenClaimsV1,
60
+ } from "@frockbot/machine-protocol";
61
+ import type { Plugin } from "cordis";
62
+ import { verifyMachinePairingCodeV1 } from "./pairing.js";
63
+
64
+ /** What one machine call carries into the User Durable Object. */
65
+ export interface MachineCallV1 {
66
+ machineId: string;
67
+ /** The token's own claims, verified at the edge. */
68
+ claims: MachineTokenClaimsV1;
69
+ /**
70
+ * `SHA-256(token)`, hex. The token itself never crosses this seam: the
71
+ * authority compares digests, so nothing downstream is handed a key.
72
+ */
73
+ tokenDigest: string;
74
+ }
75
+
76
+ export interface MachineGatewayHostV1 {
77
+ /**
78
+ * The HMAC secret every machine token and pairing code is signed with, or
79
+ * nothing. Absent means the door is closed: enrollment and every machine
80
+ * route answer 503 rather than admitting an unverified caller.
81
+ */
82
+ machineTokenSecret?: string;
83
+ createMachinePairing(
84
+ userId: string,
85
+ request: { label?: string },
86
+ ): Promise<MachinePairingOfferV1>;
87
+ enrollMachine(
88
+ userId: string,
89
+ input: { machineId: string; enrollment: unknown },
90
+ ): Promise<MachineEnrollmentReceiptV1>;
91
+ pollMachine(
92
+ userId: string,
93
+ call: MachineCallV1 & { waitSeconds: number },
94
+ ): Promise<MachinePollResultV1>;
95
+ claimMachineCommand(
96
+ userId: string,
97
+ call: MachineCallV1 & { commandId: string },
98
+ ): Promise<MachineClaimReceiptV1>;
99
+ recordMachineResult(
100
+ userId: string,
101
+ call: MachineCallV1 & { commandId: string; result: unknown },
102
+ ): Promise<MachineResultReceiptV1>;
103
+ listMachines(userId: string): Promise<MachineListViewV1>;
104
+ revokeMachine(userId: string, machineId: string): Promise<MachineListViewV1>;
105
+ }
106
+
107
+ export interface MachineBackendRouteContribution {
108
+ packageId: string;
109
+ publicRoute?(
110
+ request: Request,
111
+ url: URL,
112
+ context: { userId?: string; client: "browser" | "desktop" },
113
+ ): Promise<Response | undefined>;
114
+ route(
115
+ request: Request,
116
+ url: URL,
117
+ context: { userId?: string; client: "browser" | "desktop" },
118
+ ): Promise<Response | undefined>;
119
+ }
120
+
121
+ const PAIR = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}/pair$`);
122
+ const ENROLL = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}/enroll$`);
123
+ const LIST = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}$`);
124
+ const REVOKE = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}/([^/]+)/revoke$`);
125
+ const POLL = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}/([^/]+)/poll$`);
126
+ const CLAIM = new RegExp(
127
+ `^${MACHINE_ROUTE_PREFIX_V1}/([^/]+)/commands/([^/]+)/claim$`,
128
+ );
129
+ const RESULT = new RegExp(
130
+ `^${MACHINE_ROUTE_PREFIX_V1}/([^/]+)/commands/([^/]+)/result$`,
131
+ );
132
+
133
+ function jsonError(status: number, message: string): Response {
134
+ return Response.json({ error: message }, { status });
135
+ }
136
+
137
+ function pathSegment(value: string, label: string): string {
138
+ try {
139
+ return decodeURIComponent(value);
140
+ } catch {
141
+ throw new MachineDecodeError(`${label} is invalid`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * A machine the caller does not own, one that does not exist, and one whose
147
+ * key has been revoked are the same answer. Which it was is not a prober's.
148
+ */
149
+ function errorResponse(error: unknown): Response {
150
+ if (
151
+ typeof error === "object" &&
152
+ error !== null &&
153
+ "name" in error &&
154
+ (error.name === "MachineTokenError" ||
155
+ error.name === "MachineRegistryError") &&
156
+ "status" in error &&
157
+ typeof error.status === "number"
158
+ ) {
159
+ return jsonError(
160
+ error.status,
161
+ error instanceof Error ? error.message : "machine request failed",
162
+ );
163
+ }
164
+ if (
165
+ error instanceof MachineDecodeError ||
166
+ (typeof error === "object" &&
167
+ error !== null &&
168
+ "name" in error &&
169
+ error.name === "MachineDecodeError")
170
+ ) {
171
+ return jsonError(
172
+ 400,
173
+ error instanceof Error ? error.message : "machine request is invalid",
174
+ );
175
+ }
176
+ return jsonError(
177
+ 500,
178
+ error instanceof Error ? error.message : "machine request failed",
179
+ );
180
+ }
181
+
182
+ /** The bearer a machine route presents, or a refusal. */
183
+ function bearer(request: Request): string {
184
+ const token = machineBearerTokenV1(request.headers.get("authorization"));
185
+ if (!token) {
186
+ throw new MachineTokenError(401, "machine token is required");
187
+ }
188
+ return token;
189
+ }
190
+
191
+ async function machineCall(
192
+ secret: string,
193
+ request: Request,
194
+ machineIdSegment: string,
195
+ ): Promise<MachineCallV1> {
196
+ const token = bearer(request);
197
+ const claims = await verifyMachineTokenV1(secret, token);
198
+ const machineId = decodeMachineIdV1(
199
+ pathSegment(machineIdSegment, "machineId"),
200
+ );
201
+ // The path and the key must agree. A token for one machine presented at
202
+ // another's door is as good as forged.
203
+ if (claims.m !== machineId) {
204
+ throw new MachineTokenError(401, "machine token is invalid");
205
+ }
206
+ return { machineId, claims, tokenDigest: await machineTokenDigestV1(token) };
207
+ }
208
+
209
+ async function readJsonBody(request: Request): Promise<unknown> {
210
+ try {
211
+ return (await request.json()) as unknown;
212
+ } catch {
213
+ throw new MachineDecodeError("machine request body is not JSON");
214
+ }
215
+ }
216
+
217
+ export function createMachineBackendContribution(
218
+ host: MachineGatewayHostV1,
219
+ ): MachineBackendRouteContribution {
220
+ const secretOrRefuse = (): string => {
221
+ const secret = host.machineTokenSecret;
222
+ if (!secret) {
223
+ throw new MachineTokenError(
224
+ 503,
225
+ "machine registration is not configured",
226
+ );
227
+ }
228
+ return secret;
229
+ };
230
+
231
+ const contribution: MachineBackendRouteContribution = {
232
+ packageId: "user-machine",
233
+ async route(request, url, context) {
234
+ if (!context.userId) return undefined;
235
+ const userId = context.userId;
236
+ const revoke = REVOKE.exec(url.pathname);
237
+ const isPair = PAIR.test(url.pathname);
238
+ const isList = LIST.test(url.pathname);
239
+ if (!revoke && !isPair && !isList) return undefined;
240
+ if ([...url.searchParams.keys()].length > 0) {
241
+ return jsonError(400, "machine routes take no query parameters");
242
+ }
243
+ try {
244
+ if (isPair) {
245
+ if (request.method !== "POST") {
246
+ return jsonError(405, "method not allowed");
247
+ }
248
+ const requested = decodeMachinePairingRequestV1(
249
+ await readJsonBody(request),
250
+ );
251
+ return Response.json(
252
+ decodeMachinePairingOfferV1(
253
+ await host.createMachinePairing(userId, requested),
254
+ ),
255
+ );
256
+ }
257
+ if (revoke) {
258
+ if (request.method !== "POST") {
259
+ return jsonError(405, "method not allowed");
260
+ }
261
+ const machineId = decodeMachineIdV1(
262
+ pathSegment(revoke[1]!, "machineId"),
263
+ );
264
+ return Response.json(
265
+ decodeMachineListViewV1(
266
+ await host.revokeMachine(userId, machineId),
267
+ ),
268
+ );
269
+ }
270
+ if (request.method !== "GET") {
271
+ return jsonError(405, "method not allowed");
272
+ }
273
+ return Response.json(
274
+ decodeMachineListViewV1(await host.listMachines(userId)),
275
+ );
276
+ } catch (error) {
277
+ return errorResponse(error);
278
+ }
279
+ },
280
+ };
281
+
282
+ contribution.publicRoute = async (request, url) => {
283
+ const enroll = ENROLL.test(url.pathname);
284
+ const poll = POLL.exec(url.pathname);
285
+ const claim = CLAIM.exec(url.pathname);
286
+ const result = RESULT.exec(url.pathname);
287
+ if (!enroll && !poll && !claim && !result) return undefined;
288
+ try {
289
+ const secret = secretOrRefuse();
290
+ if (enroll) {
291
+ if (request.method !== "POST") {
292
+ return jsonError(405, "method not allowed");
293
+ }
294
+ // The pairing code is both the bearer and a field of the body: the
295
+ // header is what the edge verifies, and the body is what the authority
296
+ // hashes against the offer it stored. They must be the same code.
297
+ const code = bearer(request);
298
+ const claims = await verifyMachinePairingCodeV1(secret, code);
299
+ const body = await readJsonBody(request);
300
+ const presented = (body as { code?: unknown }).code;
301
+ if (presented !== code) {
302
+ throw new MachineTokenError(401, "machine pairing code is invalid");
303
+ }
304
+ return Response.json(
305
+ decodeMachineEnrollmentReceiptV1(
306
+ await host.enrollMachine(claims.userId, {
307
+ machineId: claims.machineId,
308
+ enrollment: body,
309
+ }),
310
+ ),
311
+ );
312
+ }
313
+ if (poll) {
314
+ if (request.method !== "GET") {
315
+ return jsonError(405, "method not allowed");
316
+ }
317
+ for (const key of url.searchParams.keys()) {
318
+ if (key !== MACHINE_POLL_WAIT_PARAM_V1) {
319
+ return jsonError(400, `machine poll query.${key} is not allowed`);
320
+ }
321
+ }
322
+ const call = await machineCall(secret, request, poll[1]!);
323
+ const raw = url.searchParams.get(MACHINE_POLL_WAIT_PARAM_V1);
324
+ const waitSeconds = raw === null ? 0 : decodeMachinePollWaitV1(raw);
325
+ return Response.json(
326
+ decodeMachinePollResultV1(
327
+ await host.pollMachine(call.claims.u, { ...call, waitSeconds }),
328
+ ),
329
+ );
330
+ }
331
+ const matched = (claim ?? result)!;
332
+ if (request.method !== "POST") {
333
+ return jsonError(405, "method not allowed");
334
+ }
335
+ const call = await machineCall(secret, request, matched[1]!);
336
+ const commandId = pathSegment(matched[2]!, "commandId");
337
+ if (claim) {
338
+ return Response.json(
339
+ decodeMachineClaimReceiptV1(
340
+ await host.claimMachineCommand(call.claims.u, {
341
+ ...call,
342
+ commandId,
343
+ }),
344
+ ),
345
+ );
346
+ }
347
+ return Response.json(
348
+ decodeMachineResultReceiptV1(
349
+ await host.recordMachineResult(call.claims.u, {
350
+ ...call,
351
+ commandId,
352
+ result: await readJsonBody(request),
353
+ }),
354
+ ),
355
+ );
356
+ } catch (error) {
357
+ return errorResponse(error);
358
+ }
359
+ };
360
+ return contribution;
361
+ }
362
+
363
+ export namespace createMachineBackendContribution {
364
+ export function plugin(
365
+ host: MachineGatewayHostV1,
366
+ lifecycle: { mount(value: MachineBackendRouteContribution): () => void },
367
+ ): Plugin {
368
+ return () => lifecycle.mount(createMachineBackendContribution(host));
369
+ }
370
+ }
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ // The row in Application settings that names the Computer section.
3
+ //
4
+ // It renders one fact — how many machines are registered and how many are
5
+ // connected right now — and hands the rest to a surface, because a registry
6
+ // with pairing codes and revocation needs more room than the advanced block
7
+ // has. `connected` is the backend's arithmetic over `lastSeenAt`, never this
8
+ // component's: a laptop that stopped polling goes offline on its own, and a UI
9
+ // that guessed would be guessing about somebody's own computer.
10
+ import { clientSurfaceRegistryKey } from "@frockbot/client-core";
11
+ import { UiAnchor, UiButton, UiIcon } from "@frockbot/client-ui";
12
+ import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
13
+ import { computed, inject, onMounted } from "vue";
14
+ import { MACHINE_SURFACE_ID_V1 } from "./index.js";
15
+ import { machinesStateKey } from "./state.js";
16
+
17
+ const surfaces = inject(clientSurfaceRegistryKey);
18
+ const providedState = inject(machinesStateKey);
19
+ if (!surfaces || !providedState) {
20
+ throw new Error("Registered machine client services were not provided");
21
+ }
22
+ const machines = providedState;
23
+ const anchorHref = settingsLinkV1({ anchor: "user-machines" });
24
+
25
+ const summary = computed(() => {
26
+ const view = machines.value.view;
27
+ if (!view) return "Not loaded yet";
28
+ const live = view.machines.filter((machine) => machine.connected).length;
29
+ if (view.machines.length === 0) return "No machines registered";
30
+ return `${view.machines.length} registered · ${live} connected`;
31
+ });
32
+
33
+ onMounted(() => machines.value.load());
34
+ </script>
35
+
36
+ <template>
37
+ <UiAnchor
38
+ as="section"
39
+ anchor="user-machines"
40
+ label="Registered machines"
41
+ :href="anchorHref"
42
+ class="machines-section"
43
+ >
44
+ <span class="machines-section__icon" aria-hidden="true"
45
+ ><UiIcon name="gear"
46
+ /></span>
47
+ <span class="machines-section__text">
48
+ <strong>Registered machines</strong>
49
+ <small>{{ summary }}</small>
50
+ </span>
51
+ <UiButton type="button" @click="surfaces.open(MACHINE_SURFACE_ID_V1)">
52
+ Open
53
+ </UiButton>
54
+ </UiAnchor>
55
+ </template>
56
+
57
+ <style scoped>
58
+ .machines-section {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 10px;
62
+ padding: 12px;
63
+ border: 1px solid var(--frock-border);
64
+ border-radius: var(--frock-radius-card);
65
+ background: var(--frock-surface-subtle);
66
+ }
67
+
68
+ .machines-section__icon {
69
+ display: grid;
70
+ width: var(--frock-avatar-sm);
71
+ height: var(--frock-avatar-sm);
72
+ flex: 0 0 auto;
73
+ place-items: center;
74
+ border-radius: 8px;
75
+ color: var(--frock-action-primary);
76
+ background: var(--frock-surface);
77
+ box-shadow: inset 0 0 0 1px var(--frock-border);
78
+ }
79
+
80
+ .machines-section__text {
81
+ display: flex;
82
+ min-width: 0;
83
+ flex: 1 1 auto;
84
+ flex-direction: column;
85
+ }
86
+
87
+ .machines-section__text strong {
88
+ color: var(--frock-text);
89
+ font-size: var(--frock-text-md);
90
+ font-weight: 600;
91
+ }
92
+
93
+ .machines-section__text small {
94
+ color: var(--frock-text-muted);
95
+ font-size: var(--frock-text-sm);
96
+ }
97
+ </style>