@frockbot/plugin-computer 0.0.0 → 0.1.1

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.
Files changed (42) hide show
  1. package/frockbot.json +25 -0
  2. package/package.json +54 -6
  3. package/src/agent.test.ts +271 -0
  4. package/src/agent.ts +1419 -0
  5. package/src/backend.test.ts +149 -0
  6. package/src/backend.ts +163 -0
  7. package/src/bot.test.ts +411 -0
  8. package/src/bot.ts +831 -0
  9. package/src/client/ComputerCard.test.ts +96 -0
  10. package/src/client/ComputerCard.vue +60 -0
  11. package/src/client/ComputerStrip.test.ts +54 -0
  12. package/src/client/ComputerStrip.vue +55 -0
  13. package/src/client/ComputerViewerOverlay.vue +252 -0
  14. package/src/client/application.test.ts +403 -0
  15. package/src/client/application.ts +359 -0
  16. package/src/client/cordis-client-shim.d.ts +16 -0
  17. package/src/client/dialog-focus.ts +13 -0
  18. package/src/client/index.ts +28 -0
  19. package/src/client/state-machine.test.ts +200 -0
  20. package/src/client/state-machine.ts +172 -0
  21. package/src/client/styles.css +594 -0
  22. package/src/client/viewer.ts +58 -0
  23. package/src/control-record.ts +57 -0
  24. package/src/doctor.test.ts +247 -0
  25. package/src/env.d.ts +12 -0
  26. package/src/index.ts +6 -0
  27. package/src/manifest.ts +3 -0
  28. package/src/process-records.test.ts +178 -0
  29. package/src/process-records.ts +278 -0
  30. package/src/process-store.ts +96 -0
  31. package/src/processes.test.ts +388 -0
  32. package/src/protocol.ts +405 -0
  33. package/src/roots.ts +6 -0
  34. package/src/screenshot.test.ts +253 -0
  35. package/src/shared-provider.test.ts +56 -0
  36. package/src/shared-provider.ts +121 -0
  37. package/src/shared.ts +54 -0
  38. package/src/sync.test.ts +255 -0
  39. package/src/workspace-fixture.ts +126 -0
  40. package/tsconfig.json +19 -0
  41. package/vite.config.ts +24 -0
  42. package/README.md +0 -3
@@ -0,0 +1,594 @@
1
+ .computer-card {
2
+ margin: 0;
3
+ }
4
+
5
+ .computer-strip {
6
+ display: grid;
7
+ width: 100%;
8
+ grid-template-columns: 72px minmax(0, 1fr);
9
+ align-items: center;
10
+ gap: 10px;
11
+ padding: 6px;
12
+ border: 0;
13
+ border-radius: var(--frock-radius-control);
14
+ color: var(--frock-text);
15
+ background: transparent;
16
+ font: inherit;
17
+ text-align: left;
18
+ cursor: pointer;
19
+ transition: background-color var(--frock-motion-fast);
20
+ }
21
+
22
+ .computer-strip:hover:not(:disabled),
23
+ .computer-strip:focus-visible {
24
+ background: var(--frock-fill-hover);
25
+ outline: none;
26
+ }
27
+
28
+ .computer-strip:disabled {
29
+ cursor: default;
30
+ }
31
+
32
+ .computer-strip-capture {
33
+ position: relative;
34
+ display: block;
35
+ overflow: hidden;
36
+ aspect-ratio: 16 / 10;
37
+ border: 1px solid var(--frock-border);
38
+ border-radius: 7px;
39
+ background: var(--frock-surface-subtle);
40
+ box-shadow: inset 0 0 0 1px var(--frock-computer-inset);
41
+ }
42
+
43
+ .computer-strip-capture img {
44
+ display: block;
45
+ width: 100%;
46
+ height: 100%;
47
+ object-fit: cover;
48
+ }
49
+
50
+ .computer-strip-placeholder {
51
+ position: absolute;
52
+ inset: 0;
53
+ display: grid;
54
+ place-items: center;
55
+ color: var(--frock-computer-accent-text);
56
+ background:
57
+ linear-gradient(var(--frock-computer-grid) 1px, transparent 1px),
58
+ linear-gradient(90deg, var(--frock-computer-grid) 1px, transparent 1px),
59
+ var(--frock-surface-subtle);
60
+ background-size: 12px 12px;
61
+ }
62
+
63
+ .computer-strip-phase {
64
+ display: flex;
65
+ min-width: 0;
66
+ align-items: center;
67
+ gap: 7px;
68
+ color: var(--frock-text-muted);
69
+ font-size: var(--frock-text-xs);
70
+ text-transform: capitalize;
71
+ }
72
+
73
+ .computer-strip-phase > :last-child {
74
+ overflow: hidden;
75
+ text-overflow: ellipsis;
76
+ white-space: nowrap;
77
+ }
78
+
79
+ .computer-strip-dot {
80
+ width: 7px;
81
+ height: 7px;
82
+ flex: 0 0 auto;
83
+ border-radius: 999px;
84
+ background: var(--frock-text-disabled);
85
+ box-shadow: 0 0 0 2px var(--frock-computer-inset);
86
+ }
87
+
88
+ .computer-strip-dot.phase-ready {
89
+ background: var(--frock-success);
90
+ }
91
+
92
+ .computer-strip-dot.phase-human-control,
93
+ .computer-strip-dot.phase-taking-control {
94
+ background: var(--frock-computer-accent);
95
+ }
96
+
97
+ .computer-strip-dot.phase-updating {
98
+ background: var(--frock-warning);
99
+ }
100
+
101
+ .computer-strip-dot.phase-error,
102
+ .computer-strip-dot.phase-disconnected {
103
+ background: var(--frock-danger-strong);
104
+ }
105
+
106
+ .computer-status {
107
+ flex: 0 0 auto;
108
+ padding: 3px 7px;
109
+ border: 1px solid var(--frock-border);
110
+ border-radius: 999px;
111
+ color: var(--frock-text-muted);
112
+ background: var(--frock-surface-subtle);
113
+ font-size: var(--frock-text-xs);
114
+ line-height: 1;
115
+ text-transform: capitalize;
116
+ }
117
+
118
+ .computer-status.status-ready {
119
+ border-color: var(--frock-computer-success-border);
120
+ color: var(--frock-success);
121
+ background: var(--frock-success-surface);
122
+ }
123
+
124
+ .computer-status.status-human-control {
125
+ border-color: var(--frock-computer-border);
126
+ color: var(--frock-computer-accent-text);
127
+ background: var(--frock-computer-surface);
128
+ }
129
+
130
+ .computer-status.status-updating {
131
+ border-color: var(--frock-warning-border);
132
+ color: var(--frock-warning);
133
+ background: var(--frock-warning-surface);
134
+ }
135
+
136
+ .computer-status.status-error,
137
+ .computer-status.status-disconnected,
138
+ .computer-status.status-unconfigured {
139
+ border-color: var(--frock-computer-error-border);
140
+ color: var(--frock-computer-error-text);
141
+ background: var(--frock-computer-error-surface);
142
+ }
143
+
144
+ .computer-screen {
145
+ position: relative;
146
+ overflow: hidden;
147
+ width: 100%;
148
+ aspect-ratio: 16 / 10;
149
+ border: 1px solid var(--frock-border);
150
+ border-radius: 7px;
151
+ background: var(--frock-surface-subtle);
152
+ box-shadow: inset 0 0 0 1px var(--frock-computer-inset);
153
+ }
154
+
155
+ button.computer-screen {
156
+ display: block;
157
+ padding: 0;
158
+ color: var(--frock-text);
159
+ font: inherit;
160
+ text-align: inherit;
161
+ }
162
+
163
+ .computer-screen > img {
164
+ display: block;
165
+ width: 100%;
166
+ height: 100%;
167
+ object-fit: cover;
168
+ }
169
+
170
+ .computer-screen-thumbnail {
171
+ cursor: zoom-in;
172
+ transition:
173
+ border-color 120ms ease,
174
+ transform 120ms ease;
175
+ }
176
+
177
+ .computer-screen-thumbnail:hover,
178
+ .computer-screen-thumbnail:focus-visible {
179
+ border-color: var(--frock-border-strong);
180
+ outline: none;
181
+ transform: translateY(-1px);
182
+ }
183
+
184
+ .computer-screen-thumbnail:disabled {
185
+ cursor: wait;
186
+ }
187
+
188
+ .computer-screen iframe {
189
+ width: 100%;
190
+ height: 100%;
191
+ border: 0;
192
+ background: var(--frock-surface);
193
+ pointer-events: none;
194
+ }
195
+
196
+ .computer-screen-expanded iframe {
197
+ pointer-events: auto;
198
+ }
199
+
200
+ .human-control .computer-screen {
201
+ border-color: var(--frock-computer-accent);
202
+ box-shadow: 0 0 0 1px var(--frock-computer-focus);
203
+ }
204
+
205
+ .human-control .computer-screen iframe {
206
+ pointer-events: auto;
207
+ }
208
+
209
+ .computer-screen-thumbnail iframe {
210
+ pointer-events: none;
211
+ }
212
+
213
+ .computer-placeholder,
214
+ .control-shield {
215
+ position: absolute;
216
+ inset: 0;
217
+ display: grid;
218
+ place-content: center;
219
+ justify-items: center;
220
+ padding: 20px;
221
+ text-align: center;
222
+ }
223
+
224
+ .computer-placeholder {
225
+ background:
226
+ linear-gradient(var(--frock-computer-grid) 1px, transparent 1px),
227
+ linear-gradient(90deg, var(--frock-computer-grid) 1px, transparent 1px),
228
+ var(--frock-surface-subtle);
229
+ background-size: 18px 18px;
230
+ }
231
+
232
+ .computer-mark {
233
+ display: grid;
234
+ width: 28px;
235
+ height: 28px;
236
+ place-items: center;
237
+ margin-bottom: 8px;
238
+ border-radius: 8px;
239
+ color: var(--frock-computer-accent-text);
240
+ background: var(--frock-computer-surface);
241
+ font-size: var(--frock-text-base);
242
+ }
243
+
244
+ .computer-placeholder strong,
245
+ .control-shield strong {
246
+ color: var(--frock-text);
247
+ font-size: var(--frock-text-sm);
248
+ font-weight: 550;
249
+ }
250
+
251
+ .computer-placeholder p,
252
+ .computer-placeholder-message,
253
+ .control-shield p {
254
+ max-width: 210px;
255
+ margin: 5px 0 10px;
256
+ color: var(--frock-text-muted);
257
+ font-size: var(--frock-text-xs);
258
+ line-height: 1.4;
259
+ }
260
+
261
+ .computer-placeholder-message {
262
+ display: block;
263
+ }
264
+
265
+ .computer-placeholder button,
266
+ .control-shield button {
267
+ padding: 5px 10px;
268
+ border: 1px solid var(--frock-border);
269
+ border-radius: 6px;
270
+ color: var(--frock-text);
271
+ background: var(--frock-surface);
272
+ font-size: var(--frock-text-xs);
273
+ cursor: pointer;
274
+ }
275
+
276
+ .computer-placeholder button:hover,
277
+ .control-shield button:hover {
278
+ border-color: var(--frock-border-strong);
279
+ background: var(--frock-surface-subtle);
280
+ }
281
+
282
+ .computer-placeholder button:disabled,
283
+ .control-shield button:disabled {
284
+ opacity: 0.55;
285
+ cursor: default;
286
+ }
287
+
288
+ .control-shield {
289
+ background: var(--frock-computer-control-tint);
290
+ backdrop-filter: blur(2px);
291
+ }
292
+
293
+ .control-shield > div {
294
+ display: grid;
295
+ justify-items: center;
296
+ padding: 13px 15px;
297
+ border: 1px solid var(--frock-border);
298
+ border-radius: 9px;
299
+ background: var(--frock-computer-control-surface);
300
+ box-shadow: var(--frock-shadow-control);
301
+ }
302
+
303
+ .computer-overlay {
304
+ position: fixed;
305
+ z-index: 5000;
306
+ inset: 0;
307
+ display: grid;
308
+ grid-template-rows: 64px minmax(0, 1fr);
309
+ color: var(--frock-viewer-text);
310
+ background: var(--frock-viewer-overlay);
311
+ backdrop-filter: blur(12px);
312
+ }
313
+
314
+ .computer-overlay-toolbar {
315
+ display: flex;
316
+ min-width: 0;
317
+ align-items: center;
318
+ justify-content: space-between;
319
+ gap: 18px;
320
+ padding: 0 22px 0 52px;
321
+ -webkit-app-region: drag;
322
+ }
323
+
324
+ .computer-overlay-identity {
325
+ min-width: 0;
326
+ }
327
+
328
+ .computer-overlay-identity strong,
329
+ .computer-overlay-identity small {
330
+ display: block;
331
+ }
332
+
333
+ .computer-overlay-identity strong {
334
+ color: var(--frock-viewer-text-strong);
335
+ font-size: var(--frock-text-md);
336
+ font-weight: 600;
337
+ }
338
+
339
+ .computer-overlay-identity small {
340
+ overflow: hidden;
341
+ max-width: 420px;
342
+ margin-top: 2px;
343
+ color: var(--frock-viewer-text-muted);
344
+ font-size: var(--frock-text-xs);
345
+ text-overflow: ellipsis;
346
+ white-space: nowrap;
347
+ }
348
+
349
+ .computer-overlay-actions {
350
+ display: flex;
351
+ align-items: center;
352
+ gap: 9px;
353
+ -webkit-app-region: no-drag;
354
+ }
355
+
356
+ .computer-overlay-actions button {
357
+ color: inherit;
358
+ font: inherit;
359
+ cursor: pointer;
360
+ }
361
+
362
+ .computer-overlay-actions button:disabled {
363
+ opacity: 0.55;
364
+ cursor: default;
365
+ }
366
+
367
+ .computer-overlay-control {
368
+ display: flex;
369
+ height: 36px;
370
+ align-items: center;
371
+ gap: 8px;
372
+ padding: 0 14px;
373
+ border: 1px solid var(--frock-viewer-border);
374
+ border-radius: 9px;
375
+ color: var(--frock-viewer-text);
376
+ background: var(--frock-viewer-surface);
377
+ box-shadow: var(--frock-viewer-shadow-control);
378
+ font-size: var(--frock-text-sm);
379
+ font-weight: 550;
380
+ }
381
+
382
+ .computer-overlay-control:hover {
383
+ border-color: var(--frock-viewer-border-hover);
384
+ background: var(--frock-viewer-surface-hover);
385
+ }
386
+
387
+ .computer-overlay-control.release-button {
388
+ border-color: var(--frock-viewer-blue-border);
389
+ color: var(--frock-viewer-blue-text);
390
+ background: var(--frock-viewer-blue-surface);
391
+ }
392
+
393
+ .computer-overlay-close {
394
+ display: grid;
395
+ width: 36px;
396
+ height: 36px;
397
+ place-items: center;
398
+ padding: 0;
399
+ border: 0;
400
+ border-radius: 8px;
401
+ color: var(--frock-viewer-close);
402
+ background: transparent;
403
+ font-size: var(--frock-text-display);
404
+ font-weight: 200;
405
+ line-height: 1;
406
+ }
407
+
408
+ .computer-overlay-close:hover {
409
+ color: var(--frock-on-accent);
410
+ background: var(--frock-viewer-close-surface);
411
+ }
412
+
413
+ .computer-overlay-stage {
414
+ min-width: 0;
415
+ min-height: 0;
416
+ padding: 12px 52px 52px;
417
+ }
418
+
419
+ .computer-screen-expanded {
420
+ width: 100%;
421
+ height: 100%;
422
+ aspect-ratio: auto;
423
+ border-color: var(--frock-viewer-frame);
424
+ border-radius: 9px;
425
+ background: var(--frock-viewer-stage);
426
+ box-shadow:
427
+ var(--frock-viewer-shadow-stage),
428
+ inset 0 0 0 1px var(--frock-viewer-grid);
429
+ }
430
+
431
+ .computer-screen-expanded .computer-placeholder strong,
432
+ .computer-screen-expanded .control-shield strong {
433
+ font-size: var(--frock-text-lg);
434
+ }
435
+
436
+ .computer-screen-expanded .computer-placeholder p,
437
+ .computer-screen-expanded .control-shield p {
438
+ max-width: 360px;
439
+ font-size: var(--frock-text-sm);
440
+ }
441
+
442
+ .computer-screen-expanded .computer-mark {
443
+ width: 42px;
444
+ height: 42px;
445
+ border-radius: 11px;
446
+ font-size: var(--frock-text-xl);
447
+ }
448
+
449
+ .computer-screen-expanded .computer-placeholder button,
450
+ .computer-screen-expanded .control-shield button {
451
+ padding: 8px 14px;
452
+ font-size: var(--frock-text-sm);
453
+ }
454
+
455
+ .computer-overlay .computer-status {
456
+ border-color: var(--frock-viewer-status-border);
457
+ color: var(--frock-viewer-text-subtle);
458
+ background: var(--frock-viewer-status-surface);
459
+ }
460
+
461
+ .computer-overlay .computer-status.status-ready {
462
+ border-color: var(--frock-viewer-success-border);
463
+ color: var(--frock-viewer-success-text);
464
+ background: var(--frock-viewer-success-surface);
465
+ }
466
+
467
+ .computer-overlay .computer-status.status-human-control {
468
+ border-color: var(--frock-viewer-control-border);
469
+ color: var(--frock-viewer-control-text);
470
+ background: var(--frock-viewer-control-surface);
471
+ }
472
+
473
+ .computer-overlay .computer-status.status-error,
474
+ .computer-overlay .computer-status.status-disconnected,
475
+ .computer-overlay .computer-status.status-unconfigured {
476
+ border-color: var(--frock-viewer-error-border);
477
+ color: var(--frock-viewer-error-text);
478
+ background: var(--frock-viewer-error-surface);
479
+ }
480
+
481
+ .computer-overlay .computer-placeholder {
482
+ background:
483
+ linear-gradient(var(--frock-viewer-grid) 1px, transparent 1px),
484
+ linear-gradient(90deg, var(--frock-viewer-grid) 1px, transparent 1px),
485
+ var(--frock-viewer-grid-surface);
486
+ }
487
+
488
+ .computer-overlay .computer-mark {
489
+ color: var(--frock-viewer-mark-text);
490
+ background: var(--frock-viewer-mark-surface);
491
+ }
492
+
493
+ .computer-overlay .computer-placeholder strong,
494
+ .computer-overlay .control-shield strong {
495
+ color: var(--frock-viewer-content-text);
496
+ }
497
+
498
+ .computer-overlay .computer-placeholder p,
499
+ .computer-overlay .control-shield p {
500
+ color: var(--frock-viewer-content-muted);
501
+ }
502
+
503
+ .computer-overlay .computer-placeholder button,
504
+ .computer-overlay .control-shield button {
505
+ border-color: var(--frock-viewer-button-border);
506
+ color: var(--frock-viewer-button-text);
507
+ background: var(--frock-viewer-button-surface);
508
+ }
509
+
510
+ .computer-overlay .computer-placeholder button:hover,
511
+ .computer-overlay .control-shield button:hover {
512
+ border-color: var(--frock-viewer-button-border-hover);
513
+ background: var(--frock-viewer-status-border);
514
+ }
515
+
516
+ .computer-overlay .control-shield {
517
+ background: var(--frock-viewer-control-shield);
518
+ }
519
+
520
+ .computer-overlay .control-shield > div {
521
+ border-color: var(--frock-viewer-panel-border);
522
+ background: var(--frock-viewer-control-panel);
523
+ box-shadow: var(--frock-viewer-shadow-panel);
524
+ }
525
+
526
+ .computer-overlay .computer-screen iframe {
527
+ background: var(--frock-viewer-close-surface);
528
+ }
529
+
530
+ .computer-confirm-backdrop {
531
+ position: fixed;
532
+ z-index: 1;
533
+ inset: 0;
534
+ display: grid;
535
+ place-items: center;
536
+ padding: 20px;
537
+ background: var(--frock-overlay-tint);
538
+ }
539
+
540
+ .computer-confirm-dialog {
541
+ width: min(420px, 100%);
542
+ padding: 24px;
543
+ border: 1px solid var(--frock-viewer-panel-border);
544
+ border-radius: var(--frock-radius-panel);
545
+ color: var(--frock-viewer-content-text);
546
+ background: var(--frock-viewer-control-panel);
547
+ box-shadow: var(--frock-viewer-shadow-panel);
548
+ }
549
+
550
+ .computer-confirm-dialog h2 {
551
+ margin: 0;
552
+ color: var(--frock-viewer-text-strong);
553
+ font-size: var(--frock-text-lg);
554
+ }
555
+
556
+ .computer-confirm-dialog p {
557
+ margin: 10px 0 22px;
558
+ color: var(--frock-viewer-content-muted);
559
+ font-size: var(--frock-text-sm);
560
+ line-height: var(--frock-leading-normal);
561
+ }
562
+
563
+ .computer-confirm-actions {
564
+ display: flex;
565
+ justify-content: flex-end;
566
+ gap: 8px;
567
+ }
568
+
569
+ @media (max-width: 700px) {
570
+ .computer-overlay {
571
+ grid-template-rows: 58px minmax(0, 1fr);
572
+ }
573
+
574
+ .computer-overlay-toolbar {
575
+ padding: 0 12px 0 18px;
576
+ }
577
+
578
+ .computer-overlay-stage {
579
+ padding: 8px 14px 18px;
580
+ }
581
+
582
+ .computer-overlay-identity,
583
+ .computer-overlay-actions > .computer-status {
584
+ display: none;
585
+ }
586
+
587
+ .computer-confirm-backdrop {
588
+ padding: 12px;
589
+ }
590
+
591
+ .computer-confirm-dialog {
592
+ padding: 20px;
593
+ }
594
+ }
@@ -0,0 +1,58 @@
1
+ import type { ComputerState } from "../shared.js";
2
+
3
+ /**
4
+ * Changes only noVNC's client-visible input fence on one minted session.
5
+ *
6
+ * The bearer token and path stay byte-for-byte inside the same URL fragment;
7
+ * control changes no server session and mints no second secret (P2).
8
+ */
9
+ export function viewerUrlForControlV1(
10
+ viewerUrl: string,
11
+ takingControl: boolean,
12
+ ): string {
13
+ const url = new URL(viewerUrl);
14
+ const fragment = new URLSearchParams(url.hash.slice(1));
15
+ fragment.set("view_only", takingControl ? "0" : "1");
16
+ url.hash = fragment.toString();
17
+ return url.toString();
18
+ }
19
+
20
+ export interface ComputerViewerActions {
21
+ requestTakeControl(): void;
22
+ cancelTakeControl(): void;
23
+ confirmTakeControl(): Promise<void>;
24
+ closeViewer(): Promise<void>;
25
+ escape(): Promise<void>;
26
+ }
27
+
28
+ /**
29
+ * The overlay's command boundary, separate from presentation for one reason:
30
+ * a Take-control click opens local confirmation and cannot reach the Bot DO
31
+ * until the second, confirmed action. Close and Escape both use the provider's
32
+ * one close path, which releases control before collapsing the viewer (P2).
33
+ */
34
+ export function createComputerViewerActions(
35
+ state: () => ComputerState,
36
+ setConfirming: (open: boolean) => void,
37
+ ): ComputerViewerActions {
38
+ return {
39
+ requestTakeControl() {
40
+ setConfirming(true);
41
+ },
42
+ cancelTakeControl() {
43
+ setConfirming(false);
44
+ },
45
+ async confirmTakeControl() {
46
+ setConfirming(false);
47
+ await state().takeControl();
48
+ },
49
+ async closeViewer() {
50
+ setConfirming(false);
51
+ await state().closeViewer();
52
+ },
53
+ async escape() {
54
+ setConfirming(false);
55
+ await state().closeViewer();
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,57 @@
1
+ /** The Bot Durable Object's Step 1 record for one human control session. */
2
+ export const COMPUTER_CONTROL_RECORD_KEY = "computer:control:v1";
3
+
4
+ export interface StoredComputerControlV1 {
5
+ version: 1;
6
+ ownerId: string;
7
+ acquiredAt: string;
8
+ expiresAt: string;
9
+ }
10
+
11
+ function record(value: unknown): Record<string, unknown> {
12
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
13
+ throw new Error("Computer control record is corrupt");
14
+ }
15
+ return value as Record<string, unknown>;
16
+ }
17
+
18
+ function text(value: unknown, label: string): string {
19
+ if (typeof value !== "string" || !value) {
20
+ throw new Error(`${label} is corrupt`);
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function timestamp(value: unknown, label: string): string {
26
+ const result = text(value, label);
27
+ if (!Number.isFinite(Date.parse(result)))
28
+ throw new Error(`${label} is corrupt`);
29
+ return result;
30
+ }
31
+
32
+ export function decodeStoredComputerControlV1(
33
+ value: unknown,
34
+ ): StoredComputerControlV1 {
35
+ const candidate = record(value);
36
+ const fields = ["version", "ownerId", "acquiredAt", "expiresAt"];
37
+ if (
38
+ candidate.version !== 1 ||
39
+ fields.some((field) => !Object.hasOwn(candidate, field)) ||
40
+ Object.keys(candidate).some((field) => !fields.includes(field))
41
+ ) {
42
+ throw new Error("Computer control record is corrupt");
43
+ }
44
+ return {
45
+ version: 1,
46
+ ownerId: text(candidate.ownerId, "Computer control ownerId"),
47
+ acquiredAt: timestamp(candidate.acquiredAt, "Computer control acquiredAt"),
48
+ expiresAt: timestamp(candidate.expiresAt, "Computer control expiresAt"),
49
+ };
50
+ }
51
+
52
+ export function isStoredComputerControlFreshV1(
53
+ record: StoredComputerControlV1,
54
+ now: Date,
55
+ ): boolean {
56
+ return Date.parse(record.expiresAt) > now.getTime();
57
+ }