@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.
- package/dist/impls/memory-bundle-storage.d.ts.map +1 -1
- package/dist/impls/memory-bundle-storage.js +25 -22
- package/dist/impls/memory-registry-storage.d.ts.map +1 -1
- package/dist/impls/memory-registry-storage.js +47 -4
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/install-command.d.ts +25 -0
- package/dist/install-command.d.ts.map +1 -0
- package/dist/install-command.js +15 -0
- package/dist/interfaces/bundle-storage.d.ts +55 -31
- package/dist/interfaces/bundle-storage.d.ts.map +1 -1
- package/dist/interfaces/registry-storage.d.ts +106 -1
- package/dist/interfaces/registry-storage.d.ts.map +1 -1
- package/dist/mcp-tool-filters.d.ts +23 -0
- package/dist/mcp-tool-filters.d.ts.map +1 -0
- package/dist/mcp-tool-filters.js +14 -0
- package/dist/ops/delete-author-key.d.ts +45 -0
- package/dist/ops/delete-author-key.d.ts.map +1 -0
- package/dist/ops/delete-author-key.js +31 -0
- package/dist/ops/list-author-keys.d.ts +34 -0
- package/dist/ops/list-author-keys.d.ts.map +1 -0
- package/dist/ops/list-author-keys.js +45 -0
- package/dist/ops/list-versions.d.ts +19 -10
- package/dist/ops/list-versions.d.ts.map +1 -1
- package/dist/ops/list-versions.js +43 -8
- package/dist/ops/private-read-authz.d.ts +88 -0
- package/dist/ops/private-read-authz.d.ts.map +1 -0
- package/dist/ops/private-read-authz.js +58 -0
- package/dist/ops/publish.d.ts +68 -1
- package/dist/ops/publish.d.ts.map +1 -1
- package/dist/ops/publish.js +292 -16
- package/dist/ops/read.d.ts +28 -6
- package/dist/ops/read.d.ts.map +1 -1
- package/dist/ops/read.js +47 -7
- package/dist/ops/register-author-key.d.ts +13 -0
- package/dist/ops/register-author-key.d.ts.map +1 -1
- package/dist/ops/register-author-key.js +42 -0
- package/dist/ops/search.d.ts +13 -1
- package/dist/ops/search.d.ts.map +1 -1
- package/dist/ops/search.js +54 -2
- package/dist/testing/bundle-storage-contract.d.ts.map +1 -1
- package/dist/testing/bundle-storage-contract.js +61 -27
- package/dist/testing/registry-storage-contract.d.ts +1 -1
- package/dist/testing/registry-storage-contract.d.ts.map +1 -1
- package/dist/testing/registry-storage-contract.js +351 -1
- package/dist/types.d.ts +257 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +79 -14
- 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 {
|
|
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
|
}
|