@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/frockbot.json +66 -0
- package/package.json +54 -6
- package/src/agent.test.ts +509 -0
- package/src/agent.ts +733 -0
- package/src/approval.test.ts +323 -0
- package/src/approval.ts +148 -0
- package/src/backend.test.ts +370 -0
- package/src/backend.ts +370 -0
- package/src/client/MachineSection.vue +97 -0
- package/src/client/MachineSurface.vue +308 -0
- package/src/client/index.test.ts +244 -0
- package/src/client/index.ts +180 -0
- package/src/client/state.ts +49 -0
- package/src/delivery.ts +145 -0
- package/src/desktop.test.ts +233 -0
- package/src/desktop.ts +238 -0
- package/src/device-runner.test.ts +326 -0
- package/src/device-runner.ts +205 -0
- package/src/device.test.ts +418 -0
- package/src/device.ts +707 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +13 -0
- package/src/intent.ts +325 -0
- package/src/manifest.ts +3 -0
- package/src/pairing.test.ts +85 -0
- package/src/pairing.ts +182 -0
- package/src/storage-keys.ts +102 -0
- package/src/store.test.ts +507 -0
- package/src/store.ts +638 -0
- package/src/target.ts +86 -0
- package/src/testing.ts +352 -0
- package/src/user.test.ts +182 -0
- package/src/user.ts +442 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
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