@wlfi-agent/cli 1.4.15 → 1.4.17

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 (82) hide show
  1. package/Cargo.lock +22 -20
  2. package/Cargo.toml +2 -2
  3. package/README.md +10 -2
  4. package/crates/vault-cli-admin/src/main.rs +21 -2
  5. package/crates/vault-cli-admin/src/tui.rs +634 -129
  6. package/crates/vault-cli-daemon/Cargo.toml +1 -0
  7. package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +122 -8
  8. package/crates/vault-cli-daemon/src/main.rs +24 -4
  9. package/crates/vault-cli-daemon/src/relay_sync.rs +155 -35
  10. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +23 -18
  11. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +6 -0
  12. package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +6 -0
  13. package/crates/vault-daemon/src/tests.rs +2 -2
  14. package/crates/vault-daemon/src/tests_parts/part4.rs +110 -0
  15. package/crates/vault-transport-unix/src/lib.rs +22 -3
  16. package/crates/vault-transport-xpc/src/lib.rs +20 -2
  17. package/dist/cli.cjs +20842 -25552
  18. package/dist/cli.cjs.map +1 -1
  19. package/package.json +5 -3
  20. package/packages/cache/.turbo/turbo-build.log +53 -52
  21. package/packages/cache/coverage/base.css +224 -0
  22. package/packages/cache/coverage/block-navigation.js +87 -0
  23. package/packages/cache/coverage/clover.xml +585 -0
  24. package/packages/cache/coverage/coverage-final.json +5 -0
  25. package/packages/cache/coverage/favicon.png +0 -0
  26. package/packages/cache/coverage/index.html +161 -0
  27. package/packages/cache/coverage/prettify.css +1 -0
  28. package/packages/cache/coverage/prettify.js +2 -0
  29. package/packages/cache/coverage/sort-arrow-sprite.png +0 -0
  30. package/packages/cache/coverage/sorter.js +210 -0
  31. package/packages/cache/coverage/src/client/index.html +116 -0
  32. package/packages/cache/coverage/src/client/index.ts.html +253 -0
  33. package/packages/cache/coverage/src/errors/index.html +116 -0
  34. package/packages/cache/coverage/src/errors/index.ts.html +244 -0
  35. package/packages/cache/coverage/src/index.html +116 -0
  36. package/packages/cache/coverage/src/index.ts.html +94 -0
  37. package/packages/cache/coverage/src/service/index.html +116 -0
  38. package/packages/cache/coverage/src/service/index.ts.html +2212 -0
  39. package/packages/cache/dist/{chunk-ALQ6H7KG.cjs → chunk-QF4XKEIA.cjs} +189 -45
  40. package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +1 -0
  41. package/packages/cache/dist/{chunk-FGJEEF5N.js → chunk-QNK6GOTI.js} +182 -38
  42. package/packages/cache/dist/chunk-QNK6GOTI.js.map +1 -0
  43. package/packages/cache/dist/index.cjs +2 -2
  44. package/packages/cache/dist/index.js +1 -1
  45. package/packages/cache/dist/service/index.cjs +2 -2
  46. package/packages/cache/dist/service/index.d.cts +2 -0
  47. package/packages/cache/dist/service/index.d.ts +2 -0
  48. package/packages/cache/dist/service/index.js +1 -1
  49. package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  50. package/packages/cache/src/service/index.test.ts +575 -0
  51. package/packages/cache/src/service/index.ts +234 -51
  52. package/packages/config/.turbo/turbo-build.log +2 -2
  53. package/packages/config/node_modules/.bin/tsc +2 -2
  54. package/packages/config/node_modules/.bin/tsserver +2 -2
  55. package/packages/config/node_modules/.bin/tsup +2 -2
  56. package/packages/config/node_modules/.bin/tsup-node +2 -2
  57. package/packages/rpc/.turbo/turbo-build.log +11 -11
  58. package/packages/rpc/node_modules/.bin/tsc +2 -2
  59. package/packages/rpc/node_modules/.bin/tsserver +2 -2
  60. package/packages/rpc/node_modules/.bin/tsup +2 -2
  61. package/packages/rpc/node_modules/.bin/tsup-node +2 -2
  62. package/packages/ui/.turbo/turbo-build.log +13 -13
  63. package/packages/ui/dist/components/badge.d.ts +1 -1
  64. package/packages/ui/dist/components/button.d.ts +1 -1
  65. package/scripts/install-rust-binaries.mjs +229 -58
  66. package/src/cli.ts +51 -39
  67. package/src/lib/admin-passthrough.js +1 -0
  68. package/src/lib/admin-reset.js +1 -0
  69. package/src/lib/admin-reset.ts +26 -16
  70. package/src/lib/admin-setup.js +1 -0
  71. package/src/lib/admin-setup.ts +32 -20
  72. package/src/lib/agent-auth-revoke.js +1 -0
  73. package/src/lib/agent-auth-rotate.js +1 -0
  74. package/src/lib/agent-auth.js +1 -0
  75. package/src/lib/config-mutation.js +1 -0
  76. package/src/lib/launchd-assets.js +1 -0
  77. package/src/lib/launchd-assets.ts +29 -0
  78. package/src/lib/local-admin-access.js +1 -0
  79. package/src/lib/rust.ts +1 -1
  80. package/src/lib/status-repair-cli.js +1 -0
  81. package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +0 -1
  82. package/packages/cache/dist/chunk-FGJEEF5N.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { describe, expect, it } from 'vitest';
3
+ import { CacheError, cacheErrorCodes } from '../errors/index.js';
3
4
  import { RelayCacheService } from './index.js';
4
5
 
5
6
  class InMemoryCacheClient {
@@ -78,6 +79,84 @@ class InMemoryCacheClient {
78
79
  }
79
80
  }
80
81
 
82
+ class ClaimRaceCacheClient extends InMemoryCacheClient {
83
+ constructor(private readonly barrierKey: string) {
84
+ super();
85
+ }
86
+
87
+ private barrierOpen = false;
88
+ private barrierPromise: Promise<void> | null = null;
89
+ private releaseBarrier: (() => void) | null = null;
90
+ private waitingReaders = 0;
91
+
92
+ override async get(key: string): Promise<string | null> {
93
+ const snapshot = await super.get(key);
94
+ if (key !== this.barrierKey || this.barrierOpen) {
95
+ return snapshot;
96
+ }
97
+
98
+ this.waitingReaders += 1;
99
+ if (!this.barrierPromise) {
100
+ this.barrierPromise = new Promise<void>((resolve) => {
101
+ this.releaseBarrier = resolve;
102
+ });
103
+ setTimeout(() => {
104
+ if (!this.barrierOpen) {
105
+ this.barrierOpen = true;
106
+ this.releaseBarrier?.();
107
+ }
108
+ }, 0);
109
+ }
110
+
111
+ if (this.waitingReaders >= 2 && !this.barrierOpen) {
112
+ this.barrierOpen = true;
113
+ this.releaseBarrier?.();
114
+ }
115
+
116
+ await this.barrierPromise;
117
+ return snapshot;
118
+ }
119
+ }
120
+
121
+ class MutableInMemoryCacheClient extends InMemoryCacheClient {
122
+ async forceGet(key: string): Promise<string | null> {
123
+ return await super.get(key);
124
+ }
125
+
126
+ async forceSet(key: string, value: string): Promise<void> {
127
+ await super.set(key, value);
128
+ }
129
+ }
130
+
131
+ async function seedPendingApproval(
132
+ service: RelayCacheService,
133
+ daemonId: string,
134
+ approvalRequestId: string,
135
+ ): Promise<void> {
136
+ await service.syncDaemonRegistration({
137
+ daemon: {
138
+ daemonId,
139
+ daemonPublicKey: 'aa'.repeat(32),
140
+ ethereumAddress: '0x9999999999999999999999999999999999999999',
141
+ lastSeenAt: new Date().toISOString(),
142
+ registeredAt: new Date().toISOString(),
143
+ status: 'active',
144
+ updatedAt: new Date().toISOString(),
145
+ },
146
+ approvalRequests: [
147
+ {
148
+ approvalRequestId,
149
+ daemonId,
150
+ destination: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
151
+ requestedAt: new Date().toISOString(),
152
+ status: 'pending',
153
+ transactionType: 'transfer_native',
154
+ updatedAt: new Date().toISOString(),
155
+ },
156
+ ],
157
+ });
158
+ }
159
+
81
160
  describe('RelayCacheService approval capability guards', () => {
82
161
  it('consumes an approval capability only once', async () => {
83
162
  const service = new RelayCacheService({
@@ -225,6 +304,85 @@ describe('RelayCacheService approval capability guards', () => {
225
304
  );
226
305
  });
227
306
 
307
+ it('preserves a rotated approval capability across later daemon syncs', async () => {
308
+ const service = new RelayCacheService({
309
+ client: new InMemoryCacheClient() as never,
310
+ namespace: 'test:relay',
311
+ });
312
+
313
+ const daemonId = '12'.repeat(32);
314
+ const originalToken = '34'.repeat(32);
315
+ const originalHash = createHash('sha256').update(originalToken).digest('hex');
316
+ const approvalRequestId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
317
+
318
+ await service.syncDaemonRegistration({
319
+ daemon: {
320
+ daemonId,
321
+ daemonPublicKey: '56'.repeat(32),
322
+ ethereumAddress: '0x9999999999999999999999999999999999999999',
323
+ lastSeenAt: new Date().toISOString(),
324
+ registeredAt: new Date().toISOString(),
325
+ status: 'active',
326
+ updatedAt: new Date().toISOString(),
327
+ },
328
+ approvalRequests: [
329
+ {
330
+ approvalRequestId,
331
+ daemonId,
332
+ destination: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
333
+ metadata: {
334
+ approvalCapabilityHash: originalHash,
335
+ approvalCapabilityToken: originalToken,
336
+ source: 'daemon',
337
+ },
338
+ requestedAt: new Date().toISOString(),
339
+ status: 'pending',
340
+ transactionType: 'transfer_native',
341
+ updatedAt: new Date().toISOString(),
342
+ },
343
+ ],
344
+ });
345
+
346
+ const rotated = await service.rotateApprovalCapability(approvalRequestId);
347
+
348
+ await service.syncDaemonRegistration({
349
+ daemon: {
350
+ daemonId,
351
+ daemonPublicKey: '56'.repeat(32),
352
+ ethereumAddress: '0x9999999999999999999999999999999999999999',
353
+ lastSeenAt: new Date().toISOString(),
354
+ registeredAt: new Date().toISOString(),
355
+ status: 'active',
356
+ updatedAt: new Date().toISOString(),
357
+ },
358
+ approvalRequests: [
359
+ {
360
+ approvalRequestId,
361
+ daemonId,
362
+ destination: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
363
+ metadata: {
364
+ approvalCapabilityHash: originalHash,
365
+ approvalCapabilityToken: originalToken,
366
+ source: 'daemon',
367
+ },
368
+ requestedAt: new Date().toISOString(),
369
+ status: 'pending',
370
+ transactionType: 'transfer_native',
371
+ updatedAt: new Date().toISOString(),
372
+ },
373
+ ],
374
+ });
375
+
376
+ const synced = await service.getApprovalRequest(approvalRequestId);
377
+
378
+ expect(synced?.metadata).toMatchObject({
379
+ source: 'daemon',
380
+ approvalCapabilityHash: rotated.metadata?.approvalCapabilityHash,
381
+ approvalCapabilityToken: rotated.metadata?.approvalCapabilityToken,
382
+ });
383
+ expect(synced?.metadata?.approvalCapabilityToken).not.toBe(originalToken);
384
+ });
385
+
228
386
  it('rejects secure approval capability rotation for non-pending approvals', async () => {
229
387
  const service = new RelayCacheService({
230
388
  client: new InMemoryCacheClient() as never,
@@ -260,4 +418,421 @@ describe('RelayCacheService approval capability guards', () => {
260
418
  service.rotateApprovalCapability('cccccccc-cccc-4ccc-8ccc-cccccccccccc'),
261
419
  ).rejects.toThrow(/cannot accept a new secure approval link/);
262
420
  });
421
+
422
+ it('tracks active manual approval updates beyond the scan window', async () => {
423
+ const service = new RelayCacheService({
424
+ client: new InMemoryCacheClient() as never,
425
+ namespace: 'test:relay',
426
+ });
427
+
428
+ const daemonId = 'ab'.repeat(32);
429
+ const approvalRequestId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
430
+ await seedPendingApproval(service, daemonId, approvalRequestId);
431
+
432
+ await service.createEncryptedUpdate({
433
+ daemonId,
434
+ metadata: {
435
+ source: 'approval_console',
436
+ },
437
+ payload: {
438
+ algorithm: 'x25519-xchacha20poly1305-v1',
439
+ ciphertextBase64: 'aa',
440
+ encapsulatedKeyBase64: 'bb',
441
+ nonceBase64: 'cc',
442
+ schemaVersion: 1,
443
+ },
444
+ targetApprovalRequestId: approvalRequestId,
445
+ type: 'manual_approval_decision',
446
+ });
447
+
448
+ for (let index = 0; index < 300; index += 1) {
449
+ await service.createEncryptedUpdate({
450
+ daemonId,
451
+ metadata: {
452
+ source: 'test-seed',
453
+ },
454
+ payload: {
455
+ algorithm: 'x25519-xchacha20poly1305-v1',
456
+ ciphertextBase64: `seed-${index}`,
457
+ encapsulatedKeyBase64: 'bb',
458
+ nonceBase64: 'cc',
459
+ schemaVersion: 1,
460
+ },
461
+ type: 'daemon_status',
462
+ });
463
+ }
464
+
465
+ await expect(service.hasActiveApprovalUpdate(daemonId, approvalRequestId)).resolves.toBe(true);
466
+ });
467
+
468
+ it('rejects duplicate manual approval updates until feedback clears the active slot', async () => {
469
+ const service = new RelayCacheService({
470
+ client: new InMemoryCacheClient() as never,
471
+ namespace: 'test:relay',
472
+ });
473
+
474
+ const daemonId = 'cd'.repeat(32);
475
+ const approvalRequestId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
476
+ await seedPendingApproval(service, daemonId, approvalRequestId);
477
+ const first = await service.createEncryptedUpdate({
478
+ daemonId,
479
+ metadata: {
480
+ source: 'approval_console',
481
+ },
482
+ payload: {
483
+ algorithm: 'x25519-xchacha20poly1305-v1',
484
+ ciphertextBase64: 'aa',
485
+ encapsulatedKeyBase64: 'bb',
486
+ nonceBase64: 'cc',
487
+ schemaVersion: 1,
488
+ },
489
+ targetApprovalRequestId: approvalRequestId,
490
+ type: 'manual_approval_decision',
491
+ });
492
+
493
+ await expect(
494
+ service.createEncryptedUpdate({
495
+ daemonId,
496
+ metadata: {
497
+ source: 'approval_console',
498
+ },
499
+ payload: {
500
+ algorithm: 'x25519-xchacha20poly1305-v1',
501
+ ciphertextBase64: 'dd',
502
+ encapsulatedKeyBase64: 'ee',
503
+ nonceBase64: 'ff',
504
+ schemaVersion: 1,
505
+ },
506
+ targetApprovalRequestId: approvalRequestId,
507
+ type: 'manual_approval_decision',
508
+ }),
509
+ ).rejects.toThrow(/already has a queued operator update/);
510
+
511
+ const [claimed] = await service.claimEncryptedUpdates({
512
+ daemonId,
513
+ leaseSeconds: 30,
514
+ limit: 1,
515
+ });
516
+
517
+ await expect(
518
+ service.submitUpdateFeedback({
519
+ claimToken: claimed.claimToken ?? '',
520
+ daemonId,
521
+ status: 'applied',
522
+ updateId: first.updateId,
523
+ }),
524
+ ).resolves.toMatchObject({
525
+ status: 'applied',
526
+ updateId: first.updateId,
527
+ });
528
+
529
+ await expect(
530
+ service.createEncryptedUpdate({
531
+ daemonId,
532
+ metadata: {
533
+ source: 'approval_console',
534
+ },
535
+ payload: {
536
+ algorithm: 'x25519-xchacha20poly1305-v1',
537
+ ciphertextBase64: '11',
538
+ encapsulatedKeyBase64: '22',
539
+ nonceBase64: '33',
540
+ schemaVersion: 1,
541
+ },
542
+ targetApprovalRequestId: approvalRequestId,
543
+ type: 'manual_approval_decision',
544
+ }),
545
+ ).resolves.toMatchObject({
546
+ daemonId,
547
+ targetApprovalRequestId: approvalRequestId,
548
+ type: 'manual_approval_decision',
549
+ });
550
+ });
551
+
552
+ it('rejects manual approval updates once the approval is no longer pending', async () => {
553
+ const service = new RelayCacheService({
554
+ client: new InMemoryCacheClient() as never,
555
+ namespace: 'test:relay',
556
+ });
557
+
558
+ const daemonId = 'de'.repeat(32);
559
+ const approvalRequestId = '12121212-1212-4212-8212-121212121212';
560
+
561
+ await service.syncDaemonRegistration({
562
+ daemon: {
563
+ daemonId,
564
+ daemonPublicKey: '34'.repeat(32),
565
+ ethereumAddress: '0x1111111111111111111111111111111111111111',
566
+ lastSeenAt: new Date().toISOString(),
567
+ registeredAt: new Date().toISOString(),
568
+ status: 'active',
569
+ updatedAt: new Date().toISOString(),
570
+ },
571
+ approvalRequests: [
572
+ {
573
+ approvalRequestId,
574
+ daemonId,
575
+ destination: '0x2222222222222222222222222222222222222222',
576
+ requestedAt: new Date().toISOString(),
577
+ status: 'approved',
578
+ transactionType: 'transfer_native',
579
+ updatedAt: new Date().toISOString(),
580
+ },
581
+ ],
582
+ });
583
+
584
+ await expect(
585
+ service.createEncryptedUpdate({
586
+ daemonId,
587
+ metadata: {
588
+ source: 'approval_console',
589
+ },
590
+ payload: {
591
+ algorithm: 'x25519-xchacha20poly1305-v1',
592
+ ciphertextBase64: 'aa',
593
+ encapsulatedKeyBase64: 'bb',
594
+ nonceBase64: 'cc',
595
+ schemaVersion: 1,
596
+ },
597
+ targetApprovalRequestId: approvalRequestId,
598
+ type: 'manual_approval_decision',
599
+ }),
600
+ ).rejects.toMatchObject<Partial<CacheError>>({
601
+ code: cacheErrorCodes.invalidPayload,
602
+ message: `Approval '${approvalRequestId}' is 'approved' and cannot accept new updates`,
603
+ });
604
+ });
605
+
606
+ it('rejects manual approval updates that target another daemon approval', async () => {
607
+ const service = new RelayCacheService({
608
+ client: new InMemoryCacheClient() as never,
609
+ namespace: 'test:relay',
610
+ });
611
+
612
+ const approvalDaemonId = '01'.repeat(32);
613
+ const updateDaemonId = '02'.repeat(32);
614
+ const approvalRequestId = '34343434-3434-4434-8434-343434343434';
615
+
616
+ await service.syncDaemonRegistration({
617
+ daemon: {
618
+ daemonId: approvalDaemonId,
619
+ daemonPublicKey: '56'.repeat(32),
620
+ ethereumAddress: '0x3333333333333333333333333333333333333333',
621
+ lastSeenAt: new Date().toISOString(),
622
+ registeredAt: new Date().toISOString(),
623
+ status: 'active',
624
+ updatedAt: new Date().toISOString(),
625
+ },
626
+ approvalRequests: [
627
+ {
628
+ approvalRequestId,
629
+ daemonId: approvalDaemonId,
630
+ destination: '0x4444444444444444444444444444444444444444',
631
+ requestedAt: new Date().toISOString(),
632
+ status: 'pending',
633
+ transactionType: 'transfer_native',
634
+ updatedAt: new Date().toISOString(),
635
+ },
636
+ ],
637
+ });
638
+
639
+ await expect(
640
+ service.createEncryptedUpdate({
641
+ daemonId: updateDaemonId,
642
+ metadata: {
643
+ source: 'approval_console',
644
+ },
645
+ payload: {
646
+ algorithm: 'x25519-xchacha20poly1305-v1',
647
+ ciphertextBase64: 'aa',
648
+ encapsulatedKeyBase64: 'bb',
649
+ nonceBase64: 'cc',
650
+ schemaVersion: 1,
651
+ },
652
+ targetApprovalRequestId: approvalRequestId,
653
+ type: 'manual_approval_decision',
654
+ }),
655
+ ).rejects.toMatchObject<Partial<CacheError>>({
656
+ code: cacheErrorCodes.invalidPayload,
657
+ message: `Approval '${approvalRequestId}' belongs to daemon '${approvalDaemonId}', not '${updateDaemonId}'`,
658
+ });
659
+ });
660
+
661
+ it('keeps the original active slot intact after rejecting a duplicate manual approval update', async () => {
662
+ const service = new RelayCacheService({
663
+ client: new InMemoryCacheClient() as never,
664
+ namespace: 'test:relay',
665
+ });
666
+
667
+ const daemonId = 'ef'.repeat(32);
668
+ const approvalRequestId = 'ffffffff-ffff-4fff-8fff-ffffffffffff';
669
+ await seedPendingApproval(service, daemonId, approvalRequestId);
670
+ const original = await service.createEncryptedUpdate({
671
+ daemonId,
672
+ metadata: {
673
+ source: 'approval_console',
674
+ },
675
+ payload: {
676
+ algorithm: 'x25519-xchacha20poly1305-v1',
677
+ ciphertextBase64: 'aa',
678
+ encapsulatedKeyBase64: 'bb',
679
+ nonceBase64: 'cc',
680
+ schemaVersion: 1,
681
+ },
682
+ targetApprovalRequestId: approvalRequestId,
683
+ type: 'manual_approval_decision',
684
+ });
685
+
686
+ await expect(
687
+ service.createEncryptedUpdate({
688
+ daemonId,
689
+ metadata: {
690
+ source: 'approval_console',
691
+ },
692
+ payload: {
693
+ algorithm: 'x25519-xchacha20poly1305-v1',
694
+ ciphertextBase64: 'dd',
695
+ encapsulatedKeyBase64: 'ee',
696
+ nonceBase64: 'ff',
697
+ schemaVersion: 1,
698
+ },
699
+ targetApprovalRequestId: approvalRequestId,
700
+ type: 'manual_approval_decision',
701
+ }),
702
+ ).rejects.toThrow(/already has a queued operator update/);
703
+
704
+ await expect(service.hasActiveApprovalUpdate(daemonId, approvalRequestId)).resolves.toBe(true);
705
+ await expect(service.getEncryptedUpdate(original.updateId)).resolves.toMatchObject({
706
+ status: 'pending',
707
+ targetApprovalRequestId: approvalRequestId,
708
+ updateId: original.updateId,
709
+ });
710
+ });
711
+
712
+ it('claims each update at most once across concurrent pollers', async () => {
713
+ const daemonId = '98'.repeat(32);
714
+ const updateId = '56565656-5656-4565-8565-565656565656';
715
+ const service = new RelayCacheService({
716
+ client: new ClaimRaceCacheClient(`test:relay:update:${updateId}`) as never,
717
+ namespace: 'test:relay',
718
+ });
719
+
720
+ await service.createEncryptedUpdate({
721
+ daemonId,
722
+ metadata: {
723
+ source: 'test-seed',
724
+ },
725
+ payload: {
726
+ algorithm: 'x25519-xchacha20poly1305-v1',
727
+ ciphertextBase64: 'aa',
728
+ encapsulatedKeyBase64: 'bb',
729
+ nonceBase64: 'cc',
730
+ schemaVersion: 1,
731
+ },
732
+ type: 'daemon_status',
733
+ updateId,
734
+ });
735
+
736
+ const [firstClaim, secondClaim] = await Promise.all([
737
+ service.claimEncryptedUpdates({
738
+ daemonId,
739
+ leaseSeconds: 30,
740
+ limit: 1,
741
+ }),
742
+ service.claimEncryptedUpdates({
743
+ daemonId,
744
+ leaseSeconds: 30,
745
+ limit: 1,
746
+ }),
747
+ ]);
748
+
749
+ expect([...firstClaim, ...secondClaim]).toHaveLength(1);
750
+ await expect(service.getEncryptedUpdate(updateId)).resolves.toMatchObject({
751
+ status: 'inflight',
752
+ updateId,
753
+ });
754
+ });
755
+
756
+ it('rejects removing an update through the wrong daemon namespace', async () => {
757
+ const service = new RelayCacheService({
758
+ client: new InMemoryCacheClient() as never,
759
+ namespace: 'test:relay',
760
+ });
761
+
762
+ const update = await service.createEncryptedUpdate({
763
+ daemonId: '10'.repeat(32),
764
+ metadata: {
765
+ source: 'test-seed',
766
+ },
767
+ payload: {
768
+ algorithm: 'x25519-xchacha20poly1305-v1',
769
+ ciphertextBase64: 'aa',
770
+ encapsulatedKeyBase64: 'bb',
771
+ nonceBase64: 'cc',
772
+ schemaVersion: 1,
773
+ },
774
+ type: 'daemon_status',
775
+ });
776
+
777
+ await expect(service.removeEncryptedUpdate('20'.repeat(32), update.updateId)).rejects.toMatchObject<
778
+ Partial<CacheError>
779
+ >({
780
+ code: cacheErrorCodes.notFound,
781
+ message: `Unknown update '${update.updateId}' for daemon '${'20'.repeat(32)}'`,
782
+ });
783
+ await expect(service.getEncryptedUpdate(update.updateId)).resolves.toMatchObject({
784
+ daemonId: '10'.repeat(32),
785
+ updateId: update.updateId,
786
+ });
787
+ });
788
+
789
+ it('preserves a foreign active slot when feedback completes an older update record', async () => {
790
+ const client = new MutableInMemoryCacheClient();
791
+ const service = new RelayCacheService({
792
+ client: client as never,
793
+ namespace: 'test:relay',
794
+ });
795
+
796
+ const daemonId = '77'.repeat(32);
797
+ const approvalRequestId = '78787878-7878-4787-8787-787878787878';
798
+ await seedPendingApproval(service, daemonId, approvalRequestId);
799
+
800
+ const update = await service.createEncryptedUpdate({
801
+ daemonId,
802
+ metadata: {
803
+ source: 'approval_console',
804
+ },
805
+ payload: {
806
+ algorithm: 'x25519-xchacha20poly1305-v1',
807
+ ciphertextBase64: 'aa',
808
+ encapsulatedKeyBase64: 'bb',
809
+ nonceBase64: 'cc',
810
+ schemaVersion: 1,
811
+ },
812
+ targetApprovalRequestId: approvalRequestId,
813
+ type: 'manual_approval_decision',
814
+ });
815
+
816
+ const [claimed] = await service.claimEncryptedUpdates({
817
+ daemonId,
818
+ leaseSeconds: 30,
819
+ limit: 1,
820
+ });
821
+ const activeApprovalKey = `test:relay:approval:${approvalRequestId}:active-update`;
822
+ await client.forceSet(activeApprovalKey, 'other-update-id');
823
+
824
+ await expect(
825
+ service.submitUpdateFeedback({
826
+ claimToken: claimed.claimToken ?? '',
827
+ daemonId,
828
+ status: 'applied',
829
+ updateId: update.updateId,
830
+ }),
831
+ ).resolves.toMatchObject({
832
+ status: 'applied',
833
+ updateId: update.updateId,
834
+ });
835
+
836
+ await expect(client.forceGet(activeApprovalKey)).resolves.toBe('other-update-id');
837
+ });
263
838
  });