@ggui-ai/registry-core 0.6.3 → 0.8.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.
Files changed (50) hide show
  1. package/dist/impls/memory-bundle-storage.d.ts.map +1 -1
  2. package/dist/impls/memory-bundle-storage.js +25 -22
  3. package/dist/impls/memory-registry-storage.d.ts.map +1 -1
  4. package/dist/impls/memory-registry-storage.js +47 -4
  5. package/dist/index.d.ts +8 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +8 -2
  8. package/dist/install-command.d.ts +25 -0
  9. package/dist/install-command.d.ts.map +1 -0
  10. package/dist/install-command.js +15 -0
  11. package/dist/interfaces/bundle-storage.d.ts +55 -31
  12. package/dist/interfaces/bundle-storage.d.ts.map +1 -1
  13. package/dist/interfaces/registry-storage.d.ts +106 -1
  14. package/dist/interfaces/registry-storage.d.ts.map +1 -1
  15. package/dist/mcp-tool-filters.d.ts +23 -0
  16. package/dist/mcp-tool-filters.d.ts.map +1 -0
  17. package/dist/mcp-tool-filters.js +14 -0
  18. package/dist/ops/delete-author-key.d.ts +45 -0
  19. package/dist/ops/delete-author-key.d.ts.map +1 -0
  20. package/dist/ops/delete-author-key.js +31 -0
  21. package/dist/ops/list-author-keys.d.ts +34 -0
  22. package/dist/ops/list-author-keys.d.ts.map +1 -0
  23. package/dist/ops/list-author-keys.js +45 -0
  24. package/dist/ops/list-versions.d.ts +19 -10
  25. package/dist/ops/list-versions.d.ts.map +1 -1
  26. package/dist/ops/list-versions.js +43 -8
  27. package/dist/ops/private-read-authz.d.ts +88 -0
  28. package/dist/ops/private-read-authz.d.ts.map +1 -0
  29. package/dist/ops/private-read-authz.js +58 -0
  30. package/dist/ops/publish.d.ts +68 -1
  31. package/dist/ops/publish.d.ts.map +1 -1
  32. package/dist/ops/publish.js +292 -16
  33. package/dist/ops/read.d.ts +28 -6
  34. package/dist/ops/read.d.ts.map +1 -1
  35. package/dist/ops/read.js +47 -7
  36. package/dist/ops/register-author-key.d.ts +13 -0
  37. package/dist/ops/register-author-key.d.ts.map +1 -1
  38. package/dist/ops/register-author-key.js +42 -0
  39. package/dist/ops/search.d.ts +13 -1
  40. package/dist/ops/search.d.ts.map +1 -1
  41. package/dist/ops/search.js +54 -2
  42. package/dist/testing/bundle-storage-contract.d.ts.map +1 -1
  43. package/dist/testing/bundle-storage-contract.js +61 -27
  44. package/dist/testing/registry-storage-contract.d.ts +1 -1
  45. package/dist/testing/registry-storage-contract.d.ts.map +1 -1
  46. package/dist/testing/registry-storage-contract.js +351 -1
  47. package/dist/types.d.ts +257 -17
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/types.js +79 -14
  50. package/package.json +20 -5
@@ -16,7 +16,8 @@
16
16
  * tests rely on isolation between cases.
17
17
  */
18
18
  import { describe, expect, it } from 'vitest';
19
- import { ARTIFACTS_METADATA_SK } from '../types.js';
19
+ import { AuthorKeyAlreadyExistsError, } from '../interfaces/registry-storage.js';
20
+ import { ARTIFACTS_METADATA_SK, SAN_ALLOWLIST_INVALID } from '../types.js';
20
21
  function makeMetadata(overrides = {}) {
21
22
  return {
22
23
  artifactId: '@test/foo',
@@ -97,6 +98,19 @@ function makeAuthorKey(overrides = {}) {
97
98
  subject: 'user-1',
98
99
  keyId: 'key-1',
99
100
  publicKeyBase64: 'BBBB',
101
+ // Row enrichment ([E], 2026-08-10) — the round-trip case carries
102
+ // BOTH optional fields so every impl proves it persists them.
103
+ createdAt: '2026-08-10T00:00:00.000Z',
104
+ label: 'ci laptop',
105
+ ...overrides,
106
+ };
107
+ }
108
+ function makeScopeOwner(overrides = {}) {
109
+ return {
110
+ scope: '@test',
111
+ ownerSubject: 'user-1',
112
+ claimedAt: '2026-08-10T00:00:00.000Z',
113
+ verification: 'unverified',
100
114
  ...overrides,
101
115
  };
102
116
  }
@@ -251,6 +265,68 @@ export function registryStorageContract(makeStorage) {
251
265
  const byTag = await storage.scanArtifacts({ q: 'geo' });
252
266
  expect(byTag.rows.map((r) => r.artifactId)).toEqual(['@a/leaflet']);
253
267
  });
268
+ // #259 delta-2 — MCP tool-binding filters share `matchesMcpToolFilters`
269
+ // semantics with the search op; every impl must honor them identically
270
+ // (the byMcpTool GSI path in the DDB adapter is the load-bearing case).
271
+ it('filters by tool (matches any entry with that tool name, with or without a server)', async () => {
272
+ const storage = makeStorage();
273
+ await storage.putArtifactMetadata(makeMetadata({
274
+ artifactId: '@a/stripe-ui',
275
+ mcpTools: [{ server: 'stripe-mcp', tool: 'create_payment' }],
276
+ mcpToolsSource: 'declared',
277
+ }));
278
+ await storage.putArtifactMetadata(makeMetadata({
279
+ artifactId: '@a/bare-ui',
280
+ mcpTools: [{ tool: 'refund_payment' }],
281
+ mcpToolsSource: 'derived',
282
+ }));
283
+ await storage.putArtifactMetadata(makeMetadata({ artifactId: '@a/unbound', mcpTools: undefined }));
284
+ const byServerBound = await storage.scanArtifacts({ tool: 'create_payment' });
285
+ expect(byServerBound.rows.map((r) => r.artifactId)).toEqual(['@a/stripe-ui']);
286
+ const byBare = await storage.scanArtifacts({ tool: 'refund_payment' });
287
+ expect(byBare.rows.map((r) => r.artifactId)).toEqual(['@a/bare-ui']);
288
+ const byMiss = await storage.scanArtifacts({ tool: 'unknown_tool' });
289
+ expect(byMiss.rows).toEqual([]);
290
+ });
291
+ it('filters by server; bare-tool entries never match', async () => {
292
+ const storage = makeStorage();
293
+ await storage.putArtifactMetadata(makeMetadata({
294
+ artifactId: '@a/bound',
295
+ mcpTools: [{ server: 'stripe-mcp', tool: 'create_payment' }],
296
+ mcpToolsSource: 'declared',
297
+ }));
298
+ await storage.putArtifactMetadata(makeMetadata({
299
+ artifactId: '@a/bare',
300
+ mcpTools: [{ tool: 'create_payment' }],
301
+ mcpToolsSource: 'derived',
302
+ }));
303
+ const page = await storage.scanArtifacts({ server: 'stripe-mcp' });
304
+ expect(page.rows.map((r) => r.artifactId)).toEqual(['@a/bound']);
305
+ });
306
+ it('tool + server composes into the exact-pair filter — cross-product is not enough', async () => {
307
+ const storage = makeStorage();
308
+ // A single artifact declaring TWO bindings — `stripe-mcp` and
309
+ // `refund_payment` each independently exist on this row, but
310
+ // never paired on the same binding entry.
311
+ await storage.putArtifactMetadata(makeMetadata({
312
+ artifactId: '@a/multi-bound',
313
+ mcpTools: [
314
+ { server: 'stripe-mcp', tool: 'create_payment' },
315
+ { server: 'other-mcp', tool: 'refund_payment' },
316
+ ],
317
+ mcpToolsSource: 'declared',
318
+ }));
319
+ const exactPair = await storage.scanArtifacts({
320
+ server: 'stripe-mcp',
321
+ tool: 'create_payment',
322
+ });
323
+ expect(exactPair.rows.map((r) => r.artifactId)).toEqual(['@a/multi-bound']);
324
+ const crossProduct = await storage.scanArtifacts({
325
+ server: 'stripe-mcp',
326
+ tool: 'refund_payment',
327
+ });
328
+ expect(crossProduct.rows).toEqual([]);
329
+ });
254
330
  });
255
331
  describe('plugin versions', () => {
256
332
  it('returns null for missing version', async () => {
@@ -443,6 +519,191 @@ export function registryStorageContract(makeStorage) {
443
519
  expect(existingBlobFetched?.refCount).toBe(1);
444
520
  });
445
521
  });
522
+ describe('scope owners', () => {
523
+ it('returns null for an unclaimed scope', async () => {
524
+ const storage = makeStorage();
525
+ expect(await storage.getScopeOwner('@nobody-claimed-this')).toBe(null);
526
+ });
527
+ it('claims an unclaimed scope and round-trips the full row', async () => {
528
+ const storage = makeStorage();
529
+ const row = makeScopeOwner();
530
+ const result = await storage.claimScope(row);
531
+ expect(result).toEqual({ ok: true });
532
+ expect(await storage.getScopeOwner(row.scope)).toEqual(row);
533
+ });
534
+ it('rejects a second claim on an already-claimed scope (first row untouched)', async () => {
535
+ const storage = makeStorage();
536
+ const first = makeScopeOwner({ ownerSubject: 'user-1' });
537
+ await storage.claimScope(first);
538
+ const second = await storage.claimScope(makeScopeOwner({ ownerSubject: 'user-2', claimedAt: '2026-08-11T00:00:00.000Z' }));
539
+ expect(second).toEqual({ conflict: true });
540
+ // First-writer-wins: the persisted row is the FIRST claimant's.
541
+ expect(await storage.getScopeOwner('@test')).toEqual(first);
542
+ });
543
+ it('a simulated race between two claims yields exactly one winner', async () => {
544
+ // Both claims run concurrently against the same fresh storage.
545
+ // The atomicity obligation (conditional create — DDB
546
+ // `attribute_not_exists(scope)`, filesystem O_EXCL, memory
547
+ // check-and-set without an interleaving await) means EXACTLY
548
+ // one may win; the loser MUST see `{ conflict: true }` and the
549
+ // persisted row MUST be the winner's.
550
+ const storage = makeStorage();
551
+ const claimA = makeScopeOwner({ ownerSubject: 'racer-a' });
552
+ const claimB = makeScopeOwner({ ownerSubject: 'racer-b' });
553
+ const [a, b] = await Promise.all([
554
+ storage.claimScope(claimA),
555
+ storage.claimScope(claimB),
556
+ ]);
557
+ const results = [
558
+ { claim: claimA, result: a },
559
+ { claim: claimB, result: b },
560
+ ];
561
+ const winners = results.filter((r) => 'ok' in r.result);
562
+ const losers = results.filter((r) => 'conflict' in r.result);
563
+ expect(winners).toHaveLength(1);
564
+ expect(losers).toHaveLength(1);
565
+ expect(await storage.getScopeOwner('@test')).toEqual(winners[0].claim);
566
+ });
567
+ it('claims are scoped — sibling scopes claim independently', async () => {
568
+ const storage = makeStorage();
569
+ const a = await storage.claimScope(makeScopeOwner({ scope: '@alpha' }));
570
+ const b = await storage.claimScope(makeScopeOwner({ scope: '@beta', ownerSubject: 'user-2' }));
571
+ expect(a).toEqual({ ok: true });
572
+ expect(b).toEqual({ ok: true });
573
+ expect((await storage.getScopeOwner('@alpha'))?.ownerSubject).toBe('user-1');
574
+ expect((await storage.getScopeOwner('@beta'))?.ownerSubject).toBe('user-2');
575
+ });
576
+ it('updateScopeOwner rewrites the full row when the expectation matches the stored snapshot', async () => {
577
+ const storage = makeStorage();
578
+ await storage.claimScope(makeScopeOwner());
579
+ const verified = {
580
+ ...makeScopeOwner(),
581
+ verification: 'verified',
582
+ verifiedDomain: 'test.example',
583
+ verifiedAt: '2026-08-12T00:00:00.000Z',
584
+ };
585
+ const result = await storage.updateScopeOwner(verified, {
586
+ ownerSubject: 'user-1',
587
+ verification: 'unverified',
588
+ });
589
+ expect(result).toEqual({ ok: true });
590
+ expect(await storage.getScopeOwner('@test')).toEqual(verified);
591
+ });
592
+ it('updateScopeOwner seeds a row when the caller expects absence (operator seed path)', async () => {
593
+ const storage = makeStorage();
594
+ const seeded = makeScopeOwner({ scope: '@seeded', ownerSubject: 'operator-chosen' });
595
+ const result = await storage.updateScopeOwner(seeded, { absent: true });
596
+ expect(result).toEqual({ ok: true });
597
+ expect(await storage.getScopeOwner('@seeded')).toEqual(seeded);
598
+ // A first-publish claim against the seeded scope now conflicts.
599
+ expect(await storage.claimScope(makeScopeOwner({ scope: '@seeded', ownerSubject: 'squatter' }))).toEqual({ conflict: true });
600
+ });
601
+ it('updateScopeOwner refuses a stale snapshot — RMW race loses, row untouched', async () => {
602
+ const storage = makeStorage();
603
+ const current = makeScopeOwner({ ownerSubject: 'current-owner' });
604
+ await storage.claimScope(current);
605
+ // Operator read an OLD snapshot (different owner) — e.g. a
606
+ // concurrent transfer landed between their read and this write.
607
+ const result = await storage.updateScopeOwner(makeScopeOwner({ ownerSubject: 'operator-target' }), { ownerSubject: 'stale-previous-owner', verification: 'unverified' });
608
+ expect(result).toEqual({ conflict: true });
609
+ expect(await storage.getScopeOwner('@test')).toEqual(current);
610
+ });
611
+ it('updateScopeOwner refuses a verification-stale snapshot', async () => {
612
+ const storage = makeStorage();
613
+ await storage.claimScope(makeScopeOwner());
614
+ // Verification flipped concurrently — the (owner, verification)
615
+ // pair is the snapshot identity, so a matching owner alone is
616
+ // not enough.
617
+ const result = await storage.updateScopeOwner(makeScopeOwner({ ownerSubject: 'new-owner' }), { ownerSubject: 'user-1', verification: 'verified' });
618
+ expect(result).toEqual({ conflict: true });
619
+ expect(await storage.getScopeOwner('@test')).toEqual(makeScopeOwner());
620
+ });
621
+ it('updateScopeOwner expect-absent refuses when a claim landed first', async () => {
622
+ const storage = makeStorage();
623
+ const claimed = makeScopeOwner({ ownerSubject: 'racing-claimant' });
624
+ await storage.claimScope(claimed);
625
+ const result = await storage.updateScopeOwner(makeScopeOwner({ ownerSubject: 'operator-target' }), { absent: true });
626
+ expect(result).toEqual({ conflict: true });
627
+ expect(await storage.getScopeOwner('@test')).toEqual(claimed);
628
+ });
629
+ it('updateScopeOwner expect-match refuses when the row is missing', async () => {
630
+ const storage = makeStorage();
631
+ const result = await storage.updateScopeOwner(makeScopeOwner(), {
632
+ ownerSubject: 'user-1',
633
+ verification: 'unverified',
634
+ });
635
+ expect(result).toEqual({ conflict: true });
636
+ expect(await storage.getScopeOwner('@test')).toBeNull();
637
+ });
638
+ it('getScopeOwner observes a completed claim immediately (conflict re-read obligation)', async () => {
639
+ // The publish gate's claim-conflict path re-reads to learn the
640
+ // winner. A read that can miss a completed claim would turn the
641
+ // losing racer's publish into a spurious storage-inconsistency
642
+ // failure — reads MUST be read-your-writes strong here.
643
+ const storage = makeStorage();
644
+ await storage.claimScope(makeScopeOwner({ ownerSubject: 'winner' }));
645
+ expect((await storage.getScopeOwner('@test'))?.ownerSubject).toBe('winner');
646
+ });
647
+ // F4 — `sanAllowlist` is the per-scope publisher-identity
648
+ // allowlist the publish gate enforces on sigstore-signed
649
+ // publishes. It's an OPTIONAL array column: a row without it
650
+ // falls back to the deployment's default identity rule, so a
651
+ // storage impl that silently drops (or fabricates) the field
652
+ // would flip a scope's identity policy without any write.
653
+ it('claimScope round-trips a sanAllowlist (F4 identity binding)', async () => {
654
+ const storage = makeStorage();
655
+ const row = makeScopeOwner({
656
+ sanAllowlist: [
657
+ 'ci@test.example',
658
+ 'https://ci.example.com/workflows/publish.yml@refs/heads/main',
659
+ ],
660
+ });
661
+ await storage.claimScope(row);
662
+ expect(await storage.getScopeOwner(row.scope)).toEqual(row);
663
+ });
664
+ it('updateScopeOwner sets a sanAllowlist on an existing row (operator path)', async () => {
665
+ const storage = makeStorage();
666
+ await storage.claimScope(makeScopeOwner());
667
+ const withAllowlist = makeScopeOwner({ sanAllowlist: ['ops@test.example'] });
668
+ const result = await storage.updateScopeOwner(withAllowlist, {
669
+ ownerSubject: 'user-1',
670
+ verification: 'unverified',
671
+ });
672
+ expect(result).toEqual({ ok: true });
673
+ expect(await storage.getScopeOwner('@test')).toEqual(withAllowlist);
674
+ });
675
+ it('the SAN_ALLOWLIST_INVALID marker round-trips as invalid — corruption never launders into a valid policy', async () => {
676
+ // Fail-closed obligation: an adapter that reads malformed
677
+ // allowlist data projects SAN_ALLOWLIST_INVALID (see
678
+ // ScopeOwnerRow.sanAllowlist). Writing the marker itself back
679
+ // (e.g. a verification flip preserving a corrupt column) MUST
680
+ // read back as invalid in every impl — the marker is a bare
681
+ // string, itself a malformed column value, so the round-trip
682
+ // cannot turn corruption into "no allowlist" or a real list.
683
+ const storage = makeStorage();
684
+ const row = makeScopeOwner({ sanAllowlist: SAN_ALLOWLIST_INVALID });
685
+ await storage.claimScope(row);
686
+ expect((await storage.getScopeOwner('@test'))?.sanAllowlist).toBe(SAN_ALLOWLIST_INVALID);
687
+ });
688
+ it('updateScopeOwner clears a sanAllowlist — the field is ABSENT after, not empty', async () => {
689
+ // Absent vs empty is load-bearing: the publish gate treats a
690
+ // missing allowlist as "no per-scope rule" (default identity
691
+ // rule applies). A cleared row that came back `[]` instead of
692
+ // absent would be indistinguishable in JS truthiness tests but
693
+ // MUST round-trip as absent so every impl agrees on the shape.
694
+ const storage = makeStorage();
695
+ await storage.claimScope(makeScopeOwner({ sanAllowlist: ['ops@test.example'] }));
696
+ const cleared = makeScopeOwner();
697
+ const result = await storage.updateScopeOwner(cleared, {
698
+ ownerSubject: 'user-1',
699
+ verification: 'unverified',
700
+ });
701
+ expect(result).toEqual({ ok: true });
702
+ const fetched = await storage.getScopeOwner('@test');
703
+ expect(fetched).toEqual(cleared);
704
+ expect(fetched?.sanAllowlist).toBeUndefined();
705
+ });
706
+ });
446
707
  describe('author keys', () => {
447
708
  it('returns null on miss', async () => {
448
709
  const storage = makeStorage();
@@ -462,6 +723,95 @@ export function registryStorageContract(makeStorage) {
462
723
  const aliceKeys = await storage.listAuthorKeys('alice');
463
724
  expect(aliceKeys.map((k) => k.keyId).sort()).toEqual(['k1', 'k2']);
464
725
  });
726
+ it('round-trips a legacy row without enrichment fields', async () => {
727
+ // Rows written before the [E] enrichment carry neither
728
+ // createdAt nor label — impls MUST NOT fabricate either.
729
+ const storage = makeStorage();
730
+ const row = {
731
+ subject: 'legacy-subject',
732
+ keyId: 'legacy-key',
733
+ publicKeyBase64: 'CCCC',
734
+ };
735
+ await storage.putAuthorKey(row);
736
+ const fetched = await storage.getAuthorKey('legacy-subject', 'legacy-key');
737
+ expect(fetched).toEqual(row);
738
+ expect(fetched?.createdAt).toBeUndefined();
739
+ expect(fetched?.label).toBeUndefined();
740
+ });
741
+ it('round-trips base64url-alphabet keyIds (- and _)', async () => {
742
+ // derivePublicKeyId emits RFC 4648 §5 base64url — the '-'/'_'
743
+ // characters are representative of the real id alphabet and
744
+ // MUST survive every impl's row-key encoding.
745
+ const storage = makeStorage();
746
+ const row = makeAuthorKey({ keyId: 'aB-cD_eF-gH_iJ-k' });
747
+ await storage.putAuthorKey(row);
748
+ expect(await storage.getAuthorKey(row.subject, row.keyId)).toEqual(row);
749
+ const listed = await storage.listAuthorKeys(row.subject);
750
+ expect(listed.map((k) => k.keyId)).toContain('aB-cD_eF-gH_iJ-k');
751
+ expect(await storage.deleteAuthorKey(row.subject, row.keyId)).toBe(true);
752
+ expect(await storage.getAuthorKey(row.subject, row.keyId)).toBe(null);
753
+ });
754
+ it("isolates subjects containing '/' — composite-key encodings must be unambiguous", async () => {
755
+ // Subjects are operator-defined free text; a naive
756
+ // `${subject}/${keyId}` composite (or a raw path component)
757
+ // makes 'team' and 'team/alice' collide or leak.
758
+ const storage = makeStorage();
759
+ await storage.putAuthorKey(makeAuthorKey({ subject: 'team/alice', keyId: 'k1' }));
760
+ await storage.putAuthorKey(makeAuthorKey({ subject: 'team', keyId: 'k2' }));
761
+ const teamKeys = await storage.listAuthorKeys('team');
762
+ expect(teamKeys.map((k) => k.keyId)).toEqual(['k2']);
763
+ const nestedKeys = await storage.listAuthorKeys('team/alice');
764
+ expect(nestedKeys.map((k) => k.keyId)).toEqual(['k1']);
765
+ // Delete under 'team' must not touch team/alice's row.
766
+ expect(await storage.deleteAuthorKey('team', 'k1')).toBe(false);
767
+ expect(await storage.getAuthorKey('team/alice', 'k1')).not.toBe(null);
768
+ });
769
+ it('putAuthorKey with ifNotExists rejects an existing row with AuthorKeyAlreadyExistsError', async () => {
770
+ // The register op's TOCTOU close relies on this conditional
771
+ // rejecting — an impl that silently upserts turns idempotent
772
+ // re-registers into false 201s.
773
+ const storage = makeStorage();
774
+ const row = makeAuthorKey();
775
+ await storage.putAuthorKey(row, { ifNotExists: true });
776
+ await expect(storage.putAuthorKey(makeAuthorKey({ publicKeyBase64: 'ZZZZ' }), { ifNotExists: true })).rejects.toThrow(AuthorKeyAlreadyExistsError);
777
+ // The stored row is untouched by the rejected write.
778
+ expect(await storage.getAuthorKey(row.subject, row.keyId)).toEqual(row);
779
+ });
780
+ it('putAuthorKey without options is an unconditional upsert', async () => {
781
+ const storage = makeStorage();
782
+ await storage.putAuthorKey(makeAuthorKey({ label: 'first' }));
783
+ await storage.putAuthorKey(makeAuthorKey({ label: 'second' }));
784
+ const row = await storage.getAuthorKey('user-1', 'key-1');
785
+ expect(row?.label).toBe('second');
786
+ });
787
+ it('deletes an existing author key and reports deleted: true', async () => {
788
+ const storage = makeStorage();
789
+ const row = makeAuthorKey();
790
+ await storage.putAuthorKey(row);
791
+ expect(await storage.deleteAuthorKey(row.subject, row.keyId)).toBe(true);
792
+ expect(await storage.getAuthorKey(row.subject, row.keyId)).toBe(null);
793
+ expect(await storage.listAuthorKeys(row.subject)).toEqual([]);
794
+ });
795
+ it('delete of an absent key reports deleted: false (idempotent)', async () => {
796
+ const storage = makeStorage();
797
+ expect(await storage.deleteAuthorKey('nobody', 'nope')).toBe(false);
798
+ // Second delete of a just-deleted row is the same absent case.
799
+ const row = makeAuthorKey();
800
+ await storage.putAuthorKey(row);
801
+ await storage.deleteAuthorKey(row.subject, row.keyId);
802
+ expect(await storage.deleteAuthorKey(row.subject, row.keyId)).toBe(false);
803
+ });
804
+ it('delete is isolated per subject — another subject\'s same keyId survives', async () => {
805
+ const storage = makeStorage();
806
+ await storage.putAuthorKey(makeAuthorKey({ subject: 'alice', keyId: 'k1' }));
807
+ await storage.putAuthorKey(makeAuthorKey({ subject: 'bob', keyId: 'k1' }));
808
+ // Deleting under a subject that has no such key touches nothing.
809
+ expect(await storage.deleteAuthorKey('charlie', 'k1')).toBe(false);
810
+ // Alice's delete removes ONLY alice's row.
811
+ expect(await storage.deleteAuthorKey('alice', 'k1')).toBe(true);
812
+ expect(await storage.getAuthorKey('alice', 'k1')).toBe(null);
813
+ expect(await storage.getAuthorKey('bob', 'k1')).not.toBe(null);
814
+ });
465
815
  });
466
816
  });
467
817
  }