@ggui-ai/registry-core 0.6.2 → 0.7.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.
@@ -6,6 +6,23 @@
6
6
  * 1. Validate {@link AuthnContext} present (transport enforces auth
7
7
  * before calling; this is defense-in-depth).
8
8
  * 2. Parse + validate the manifest via `parseArtifactManifest`.
9
+ * 2b. Enforce the visibility ↔ signature-algorithm pairing
10
+ * (`public` ⇒ `sigstore-cosign`, `private` ⇒ `ed25519`) —
11
+ * cheap field comparison, before any decode or verify work.
12
+ * 2c. Enforce scope ownership: a scope owned by another subject
13
+ * answers 403 `scope_forbidden`; an UNCLAIMED scope on the
14
+ * reserved list ({@link RESERVED_SCOPES}, replaceable via
15
+ * {@link PublishArtifactDeps.reservedScopes}) is never
16
+ * claimable, while a reserved scope WITH an ownership row
17
+ * (operator-seeded) follows the normal owner rule; any other
18
+ * unclaimed scope is claimed for the caller via the atomic
19
+ * {@link RegistryStorage.claimScope} (first-writer-wins; a lost
20
+ * race re-reads and re-applies the owner check). NOTE — the
21
+ * claim is durable even when a LATER gate fails this publish:
22
+ * the caller demonstrated intent, a failed-publish claim stays
23
+ * re-usable by the same caller, and an unverified claim remains
24
+ * reclaimable by the registry operator. Deliberately the
25
+ * simplest correct behavior.
9
26
  * 3. Decode + size-check the bundle (gadgets only).
10
27
  * 4. Recompute SHA-384 of the bundle bytes; compare to client claim.
11
28
  * 5. Re-run the conformance gate ({@link checkConformance}).
@@ -21,9 +38,9 @@
21
38
  * `latestVersion` when the new version is the highest semver.
22
39
  * 10. Return 201 with the wire-locked {@link PublishResponseBody}.
23
40
  */
24
- import { parseArtifactManifest, } from '@ggui-ai/artifact-manifest';
41
+ import { manifestToRegistryEntry, parseArtifactManifest, } from '@ggui-ai/artifact-manifest';
25
42
  import { canonicalJson, extractSigstoreLeafCertPem, isGadgetSignature, verifyBundleEd25519, verifyBundleSigstore, } from '@ggui-ai/gadget-signing';
26
- import { bundleHostScheme } from '@ggui-ai/protocol';
43
+ import { bundleHostScheme, strictGadgetDescriptorSchema } from '@ggui-ai/protocol';
27
44
  import { ZodError } from 'zod';
28
45
  import { ARTIFACTS_METADATA_SK } from '../types.js';
29
46
  import { safeBase64Decode, sha384Base64 } from '../utils/base64.js';
@@ -38,6 +55,36 @@ import { checkConformance } from './conformance.js';
38
55
  * against an OSS-mirrored registry without re-bundling).
39
56
  */
40
57
  export const MAX_BUNDLE_BYTES = 5 * 1024 * 1024;
58
+ /**
59
+ * Scopes no publish may CLAIM — well-known names whose squatting would
60
+ * mislead installers about who authored an artifact. The default
61
+ * covers first-party names plus obvious squat-bait; a deployment
62
+ * REPLACES the whole list via
63
+ * {@link PublishArtifactDeps.reservedScopes} (spread this constant to
64
+ * extend it instead).
65
+ *
66
+ * Reserved scopes block first-publish CLAIMS only — not owned
67
+ * publishes. A registry operator who wants artifacts under a reserved
68
+ * name seeds its ownership row out-of-band via
69
+ * {@link RegistryStorage.updateScopeOwner}; once the row exists, the
70
+ * normal owner rule applies (the seeded owner publishes, everyone else
71
+ * gets `scope_forbidden`) with no change to this list.
72
+ */
73
+ export const RESERVED_SCOPES = [
74
+ '@ggui-ai',
75
+ '@ggui',
76
+ '@guuey',
77
+ '@anthropic',
78
+ '@claude',
79
+ '@openai',
80
+ '@google',
81
+ '@gemini',
82
+ '@meta',
83
+ '@microsoft',
84
+ '@aws',
85
+ '@amazon',
86
+ '@apple',
87
+ ];
41
88
  export async function publishArtifact(input, deps) {
42
89
  // 1. Authn — transport enforces before calling, but defensive read.
43
90
  if (typeof deps.authn.subject !== 'string' || deps.authn.subject.length === 0) {
@@ -66,6 +113,75 @@ export async function publishArtifact(input, deps) {
66
113
  }
67
114
  const artifactId = `${manifest.scope}/${manifest.name}`;
68
115
  const version = manifest.version;
116
+ // 2b. Visibility ↔ signature-algorithm pairing. The two algorithms
117
+ // carry different trust models: sigstore keyless signing records
118
+ // every publish in a public transparency log — the third-party
119
+ // auditability that makes an artifact safe to list publicly — while
120
+ // an Ed25519 author key leaves no public record, which is the point
121
+ // for private artifacts. Clients pair them at signing time, but the
122
+ // server cannot trust a hand-rolled request: an unenforced
123
+ // public+Ed25519 publish would become publicly listable with no
124
+ // transparency-log entry. Cheap field comparison — runs before any
125
+ // bundle decode, conformance, or cryptographic verify work.
126
+ if (manifest.visibility === 'public' && input.signature.algorithm === 'ed25519') {
127
+ return error(400, 'visibility_algorithm_mismatch', "`visibility: 'public'` requires a sigstore keyless signature (`algorithm: 'sigstore-cosign'`) so the publish is recorded in a public transparency log. Ed25519 author-key signatures pair with `visibility: 'private'` — re-sign with sigstore, or publish as private.");
128
+ }
129
+ if (manifest.visibility === 'private' && input.signature.algorithm === 'sigstore-cosign') {
130
+ return error(400, 'visibility_algorithm_mismatch', "`visibility: 'private'` requires an Ed25519 author-key signature. Sigstore keyless signing (`algorithm: 'sigstore-cosign'`) records the publish in a public transparency log and pairs with `visibility: 'public'` — re-sign with your Ed25519 author key, or publish as public.");
131
+ }
132
+ // 2c. Scope ownership. Runs after the pairing check (2b) and before
133
+ // any bundle decode — ownership is a cheap read and a forbidden
134
+ // publish must not pay for (or leak errors from) bundle work.
135
+ //
136
+ // Order within the gate: owner lookup first, then — only when the
137
+ // scope is UNCLAIMED — the reserved denylist, then the first-publish
138
+ // claim. Reserved scopes block CLAIMS, not owned publishes: a scope
139
+ // whose ownership row exists (seeded by the registry operator, or
140
+ // grandfathered) follows the normal owner rule, so the operator can
141
+ // publish first-party artifacts under a reserved name without
142
+ // touching the reserved list. A lost claim race re-reads and
143
+ // re-applies the owner check — the conditional create in
144
+ // `claimScope` is the only race-safe primitive, so `conflict` means
145
+ // somebody else's row is now durable and the re-read decides whose
146
+ // scope this is.
147
+ //
148
+ // The claim is DURABLE even when a later gate fails this publish
149
+ // (documented judgment call — see the flow docstring). The claim
150
+ // also deliberately lands BEFORE signature verification: moving it
151
+ // after would not stop a motivated squatter (any authenticated
152
+ // caller can produce a validly-signed private publish), so the real
153
+ // defenses against mass squatting are the operator reclaim flow, the
154
+ // audit trail, and (future) rate limiting — while the early claim
155
+ // keeps the gate order cheap-first.
156
+ const scopeForbiddenByOwner = () => error(403, 'scope_forbidden', `scope \`${manifest.scope}\` is owned by another publisher. Choose a scope you own — your first publish into an unclaimed scope claims it. If you hold the rights to this name (for example the matching domain or brand), the registry operator can verify that ownership and reclaim an unverified scope.`);
157
+ const existingOwner = await deps.storage.getScopeOwner(manifest.scope);
158
+ if (existingOwner !== null && existingOwner.ownerSubject !== deps.authn.subject) {
159
+ return scopeForbiddenByOwner();
160
+ }
161
+ if (existingOwner === null) {
162
+ const reservedScopes = deps.reservedScopes ?? RESERVED_SCOPES;
163
+ if (reservedScopes.includes(manifest.scope)) {
164
+ return error(403, 'scope_forbidden', `scope \`${manifest.scope}\` is reserved on this registry and cannot be claimed by publishing. Choose a scope you own — your first publish into an unclaimed scope claims it.`);
165
+ }
166
+ const claim = await deps.storage.claimScope({
167
+ scope: manifest.scope,
168
+ ownerSubject: deps.authn.subject,
169
+ claimedAt: deps.clock().toISOString(),
170
+ verification: 'unverified',
171
+ });
172
+ if ('conflict' in claim) {
173
+ // Lost the race — re-read and re-apply the owner check.
174
+ const winner = await deps.storage.getScopeOwner(manifest.scope);
175
+ if (winner === null) {
176
+ // claimScope reported an existing row but the re-read found
177
+ // none — storage-layer inconsistency, not a policy outcome.
178
+ return error(500, 'internal', `scope claim for \`${manifest.scope}\` conflicted but no ownership row exists — storage inconsistency`);
179
+ }
180
+ if (winner.ownerSubject !== deps.authn.subject) {
181
+ return scopeForbiddenByOwner();
182
+ }
183
+ }
184
+ }
69
185
  // 3. Bundle decode + size (gadgets only)
70
186
  let bundleBytes;
71
187
  if (manifest.kind === 'gadget') {
@@ -88,6 +204,31 @@ export async function publishArtifact(input, deps) {
88
204
  if (recomputed !== input.bundleSha384) {
89
205
  return error(400, 'bundle_hash_mismatch', 'server-computed SHA-384 of the bundle does not match the client-supplied `bundleSha384`', { expected: recomputed, received: input.bundleSha384 });
90
206
  }
207
+ // 4b. Projection viability — the install path projects this
208
+ // manifest into a catalog row (`manifestToRegistryEntry` →
209
+ // `strictGadgetDescriptorSchema`). The two schemas are separate
210
+ // validators (e.g. manifest `connect[]` entries are free-form
211
+ // strings while catalog `connect[]` entries must be full URLs),
212
+ // and a published version is immutable — a manifest that projects
213
+ // to an invalid row would be PERMANENTLY uninstallable. Reject it
214
+ // now, naming the offending field, while the author can still fix
215
+ // and republish.
216
+ const projected = manifestToRegistryEntry(manifest, {
217
+ version: manifest.version,
218
+ // Representative install-time computed fields: the bundle URL is
219
+ // stamped by storage later, so a syntactically valid placeholder
220
+ // stands in; the SRI is the real digest verified above.
221
+ bundleUrl: 'https://registry.invalid/bundle.js',
222
+ bundleSri: `sha384-${recomputed}`,
223
+ });
224
+ const projectionCheck = strictGadgetDescriptorSchema.safeParse(projected);
225
+ if (!projectionCheck.success) {
226
+ const first = projectionCheck.error.issues[0];
227
+ const path = (first?.path ?? [])
228
+ .map((seg) => String(seg))
229
+ .join('.');
230
+ return error(400, 'manifest_invalid', `manifest projects to an invalid gadget catalog row at \`${path}\`: ${first?.message ?? 'schema violation'} — installs would reject this artifact, and published versions are immutable. Fix the field and republish.`, { path, issues: projectionCheck.error.issues });
231
+ }
91
232
  }
92
233
  // 5. Conformance gate
93
234
  const conformanceBundleText = bundleBytes === undefined
@@ -150,6 +291,7 @@ export async function publishArtifact(input, deps) {
150
291
  const verifyResult = await verifyBundleSigstore({
151
292
  bundleBytes: bytesForSignature,
152
293
  signature: input.signature,
294
+ ...(deps.sigstoreTuf ?? {}),
153
295
  });
154
296
  if (!verifyResult.valid) {
155
297
  return error(400, 'signature_invalid', verifyResult.reason);
@@ -160,7 +302,7 @@ export async function publishArtifact(input, deps) {
160
302
  // impl (single source of truth for the bundle shape).
161
303
  const leafCertPem = extractSigstoreLeafCertPem(input.signature);
162
304
  if (leafCertPem === undefined) {
163
- return error(400, 'signature_invalid', 'sigstore verify succeeded but bundle is missing `verificationMaterial.x509CertificateChain.certificates[0].rawBytes` — cannot pin author identity on the version row');
305
+ return error(400, 'signature_invalid', 'sigstore verify succeeded but bundle carries no leaf certificate — expected `verificationMaterial.certificate.rawBytes` (bundle v0.3) or `verificationMaterial.x509CertificateChain.certificates[0].rawBytes` (v0.1/v0.2) — cannot pin author identity on the version row');
164
306
  }
165
307
  authorPublicKey = leafCertPem;
166
308
  }
@@ -3,10 +3,21 @@ import { type RegistryStorage } from '../interfaces/registry-storage.js';
3
3
  import type { RegisterAuthorKeyErrorBody, RegisterAuthorKeyRequestBody, RegisterAuthorKeyResponseBody } from '../types.js';
4
4
  export interface RegisterAuthorKeyInput {
5
5
  readonly publicKeyBase64: string;
6
+ /**
7
+ * Optional human-readable name for the key ([E] enrichment) —
8
+ * `ggui keys register --label …`. Trimmed; whitespace-only is
9
+ * treated as absent; longer than {@link MAX_LABEL_LENGTH} is a 400.
10
+ */
11
+ readonly label?: string;
6
12
  }
7
13
  export interface RegisterAuthorKeyDeps {
8
14
  readonly storage: RegistryStorage;
9
15
  readonly authn: AuthnContext;
16
+ /**
17
+ * Wall-clock provider for the row's `createdAt` stamp —
18
+ * overridable for deterministic tests. Defaults to `new Date()`.
19
+ */
20
+ readonly clock?: () => Date;
10
21
  }
11
22
  export type RegisterAuthorKeyResult = {
12
23
  readonly ok: true;
@@ -17,6 +28,8 @@ export type RegisterAuthorKeyResult = {
17
28
  readonly status: 400 | 409 | 500;
18
29
  readonly body: RegisterAuthorKeyErrorBody;
19
30
  };
31
+ /** Display-name budget — a label is a short human hint, not a document. */
32
+ export declare const MAX_LABEL_LENGTH = 100;
20
33
  export declare function registerAuthorKey(input: RegisterAuthorKeyInput, deps: RegisterAuthorKeyDeps): Promise<RegisterAuthorKeyResult>;
21
34
  export type { RegisterAuthorKeyErrorBody, RegisterAuthorKeyRequestBody, RegisterAuthorKeyResponseBody, };
22
35
  //# sourceMappingURL=register-author-key.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"register-author-key.d.ts","sourceRoot":"","sources":["../../src/ops/register-author-key.ts"],"names":[],"mappings":"AAuCA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,mCAAmC,CAAC;AAE3C,OAAO,KAAK,EAEV,0BAA0B,EAC1B,4BAA4B,EAC5B,6BAA6B,EAC9B,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;CAC9B;AAED,MAAM,MAAM,uBAAuB,GAC/B;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,6BAA6B,CAAC;CAC9C,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,0BAA0B,CAAC;CAC3C,CAAC;AAIN,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,sBAAsB,EAC7B,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,uBAAuB,CAAC,CAuIlC;AAID,YAAY,EACV,0BAA0B,EAC1B,4BAA4B,EAC5B,6BAA6B,GAC9B,CAAC"}
1
+ {"version":3,"file":"register-author-key.d.ts","sourceRoot":"","sources":["../../src/ops/register-author-key.ts"],"names":[],"mappings":"AAuCA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,mCAAmC,CAAC;AAE3C,OAAO,KAAK,EAEV,0BAA0B,EAC1B,4BAA4B,EAC5B,6BAA6B,EAC9B,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;CAC7B;AAED,MAAM,MAAM,uBAAuB,GAC/B;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,6BAA6B,CAAC;CAC9C,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,0BAA0B,CAAC;CAC3C,CAAC;AAIN,2EAA2E;AAC3E,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,sBAAsB,EAC7B,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,uBAAuB,CAAC,CAiLlC;AAID,YAAY,EACV,0BAA0B,EAC1B,4BAA4B,EAC5B,6BAA6B,GAC9B,CAAC"}
@@ -40,6 +40,8 @@ import { derivePublicKeyId } from '@ggui-ai/gadget-signing';
40
40
  import { AuthorKeyAlreadyExistsError, } from '../interfaces/registry-storage.js';
41
41
  import { safeBase64Decode } from '../utils/base64.js';
42
42
  const ED25519_PUBLIC_KEY_BYTES = 32;
43
+ /** Display-name budget — a label is a short human hint, not a document. */
44
+ export const MAX_LABEL_LENGTH = 100;
43
45
  export async function registerAuthorKey(input, deps) {
44
46
  if (typeof input.publicKeyBase64 !== 'string' ||
45
47
  input.publicKeyBase64.length === 0) {
@@ -73,12 +75,42 @@ export async function registerAuthorKey(input, deps) {
73
75
  },
74
76
  };
75
77
  }
78
+ // Label ([E] enrichment) — defend with a typeof check even though the
79
+ // input type says string (transports hand over parsed JSON); trim;
80
+ // whitespace-only collapses to absent.
81
+ if (input.label !== undefined && typeof input.label !== 'string') {
82
+ return {
83
+ ok: false,
84
+ status: 400,
85
+ body: {
86
+ error: 'invalid_request',
87
+ message: '`label` must be a string',
88
+ },
89
+ };
90
+ }
91
+ const trimmedLabel = input.label?.trim();
92
+ const label = trimmedLabel !== undefined && trimmedLabel.length > 0
93
+ ? trimmedLabel
94
+ : undefined;
95
+ if (label !== undefined && label.length > MAX_LABEL_LENGTH) {
96
+ return {
97
+ ok: false,
98
+ status: 400,
99
+ body: {
100
+ error: 'invalid_request',
101
+ message: `\`label\` must be at most ${MAX_LABEL_LENGTH} characters (got ${label.length})`,
102
+ },
103
+ };
104
+ }
76
105
  const keyId = derivePublicKeyId(decoded);
77
106
  const subject = deps.authn.subject;
107
+ const clock = deps.clock ?? (() => new Date());
78
108
  const row = {
79
109
  subject,
80
110
  keyId,
81
111
  publicKeyBase64: input.publicKeyBase64,
112
+ createdAt: clock().toISOString(),
113
+ ...(label !== undefined ? { label } : {}),
82
114
  };
83
115
  // Atomic first-write attempt. The `ifNotExists` flag tells the
84
116
  // storage adapter to use a conditional put (a DDB
@@ -103,6 +135,8 @@ export async function registerAuthorKey(input, deps) {
103
135
  subject,
104
136
  keyId,
105
137
  publicKeyBase64: input.publicKeyBase64,
138
+ ...(row.createdAt !== undefined ? { createdAt: row.createdAt } : {}),
139
+ ...(row.label !== undefined ? { label: row.label } : {}),
106
140
  },
107
141
  };
108
142
  }
@@ -150,6 +184,10 @@ export async function registerAuthorKey(input, deps) {
150
184
  };
151
185
  }
152
186
  if (existing.publicKeyBase64 === input.publicKeyBase64) {
187
+ // Idempotent re-register echoes the EXISTING row untouched — a
188
+ // different label on a retry does NOT rewrite the stored one
189
+ // (relabeling is a deliberate future op, not a register side
190
+ // effect).
153
191
  return {
154
192
  ok: true,
155
193
  status: 200,
@@ -157,6 +195,10 @@ export async function registerAuthorKey(input, deps) {
157
195
  subject: existing.subject,
158
196
  keyId: existing.keyId,
159
197
  publicKeyBase64: existing.publicKeyBase64,
198
+ ...(existing.createdAt !== undefined
199
+ ? { createdAt: existing.createdAt }
200
+ : {}),
201
+ ...(existing.label !== undefined ? { label: existing.label } : {}),
160
202
  },
161
203
  };
162
204
  }
@@ -1,3 +1,3 @@
1
- import type { RegistryStorage } from '../interfaces/registry-storage.js';
1
+ import { type RegistryStorage } from '../interfaces/registry-storage.js';
2
2
  export declare function registryStorageContract(makeStorage: () => RegistryStorage): void;
3
3
  //# sourceMappingURL=registry-storage-contract.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"registry-storage-contract.d.ts","sourceRoot":"","sources":["../../src/testing/registry-storage-contract.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAsHzE,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,eAAe,GAAG,IAAI,CA+ZhF"}
1
+ {"version":3,"file":"registry-storage-contract.d.ts","sourceRoot":"","sources":["../../src/testing/registry-storage-contract.ts"],"names":[],"mappings":"AAkBA,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,mCAAmC,CAAC;AAqI3C,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,eAAe,GAAG,IAAI,CAkqBhF"}
@@ -16,6 +16,7 @@
16
16
  * tests rely on isolation between cases.
17
17
  */
18
18
  import { describe, expect, it } from 'vitest';
19
+ import { AuthorKeyAlreadyExistsError, } from '../interfaces/registry-storage.js';
19
20
  import { ARTIFACTS_METADATA_SK } from '../types.js';
20
21
  function makeMetadata(overrides = {}) {
21
22
  return {
@@ -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
  }
@@ -443,6 +457,132 @@ export function registryStorageContract(makeStorage) {
443
457
  expect(existingBlobFetched?.refCount).toBe(1);
444
458
  });
445
459
  });
460
+ describe('scope owners', () => {
461
+ it('returns null for an unclaimed scope', async () => {
462
+ const storage = makeStorage();
463
+ expect(await storage.getScopeOwner('@nobody-claimed-this')).toBe(null);
464
+ });
465
+ it('claims an unclaimed scope and round-trips the full row', async () => {
466
+ const storage = makeStorage();
467
+ const row = makeScopeOwner();
468
+ const result = await storage.claimScope(row);
469
+ expect(result).toEqual({ ok: true });
470
+ expect(await storage.getScopeOwner(row.scope)).toEqual(row);
471
+ });
472
+ it('rejects a second claim on an already-claimed scope (first row untouched)', async () => {
473
+ const storage = makeStorage();
474
+ const first = makeScopeOwner({ ownerSubject: 'user-1' });
475
+ await storage.claimScope(first);
476
+ const second = await storage.claimScope(makeScopeOwner({ ownerSubject: 'user-2', claimedAt: '2026-08-11T00:00:00.000Z' }));
477
+ expect(second).toEqual({ conflict: true });
478
+ // First-writer-wins: the persisted row is the FIRST claimant's.
479
+ expect(await storage.getScopeOwner('@test')).toEqual(first);
480
+ });
481
+ it('a simulated race between two claims yields exactly one winner', async () => {
482
+ // Both claims run concurrently against the same fresh storage.
483
+ // The atomicity obligation (conditional create — DDB
484
+ // `attribute_not_exists(scope)`, filesystem O_EXCL, memory
485
+ // check-and-set without an interleaving await) means EXACTLY
486
+ // one may win; the loser MUST see `{ conflict: true }` and the
487
+ // persisted row MUST be the winner's.
488
+ const storage = makeStorage();
489
+ const claimA = makeScopeOwner({ ownerSubject: 'racer-a' });
490
+ const claimB = makeScopeOwner({ ownerSubject: 'racer-b' });
491
+ const [a, b] = await Promise.all([
492
+ storage.claimScope(claimA),
493
+ storage.claimScope(claimB),
494
+ ]);
495
+ const results = [
496
+ { claim: claimA, result: a },
497
+ { claim: claimB, result: b },
498
+ ];
499
+ const winners = results.filter((r) => 'ok' in r.result);
500
+ const losers = results.filter((r) => 'conflict' in r.result);
501
+ expect(winners).toHaveLength(1);
502
+ expect(losers).toHaveLength(1);
503
+ expect(await storage.getScopeOwner('@test')).toEqual(winners[0].claim);
504
+ });
505
+ it('claims are scoped — sibling scopes claim independently', async () => {
506
+ const storage = makeStorage();
507
+ const a = await storage.claimScope(makeScopeOwner({ scope: '@alpha' }));
508
+ const b = await storage.claimScope(makeScopeOwner({ scope: '@beta', ownerSubject: 'user-2' }));
509
+ expect(a).toEqual({ ok: true });
510
+ expect(b).toEqual({ ok: true });
511
+ expect((await storage.getScopeOwner('@alpha'))?.ownerSubject).toBe('user-1');
512
+ expect((await storage.getScopeOwner('@beta'))?.ownerSubject).toBe('user-2');
513
+ });
514
+ it('updateScopeOwner rewrites the full row when the expectation matches the stored snapshot', async () => {
515
+ const storage = makeStorage();
516
+ await storage.claimScope(makeScopeOwner());
517
+ const verified = {
518
+ ...makeScopeOwner(),
519
+ verification: 'verified',
520
+ verifiedDomain: 'test.example',
521
+ verifiedAt: '2026-08-12T00:00:00.000Z',
522
+ };
523
+ const result = await storage.updateScopeOwner(verified, {
524
+ ownerSubject: 'user-1',
525
+ verification: 'unverified',
526
+ });
527
+ expect(result).toEqual({ ok: true });
528
+ expect(await storage.getScopeOwner('@test')).toEqual(verified);
529
+ });
530
+ it('updateScopeOwner seeds a row when the caller expects absence (operator seed path)', async () => {
531
+ const storage = makeStorage();
532
+ const seeded = makeScopeOwner({ scope: '@seeded', ownerSubject: 'operator-chosen' });
533
+ const result = await storage.updateScopeOwner(seeded, { absent: true });
534
+ expect(result).toEqual({ ok: true });
535
+ expect(await storage.getScopeOwner('@seeded')).toEqual(seeded);
536
+ // A first-publish claim against the seeded scope now conflicts.
537
+ expect(await storage.claimScope(makeScopeOwner({ scope: '@seeded', ownerSubject: 'squatter' }))).toEqual({ conflict: true });
538
+ });
539
+ it('updateScopeOwner refuses a stale snapshot — RMW race loses, row untouched', async () => {
540
+ const storage = makeStorage();
541
+ const current = makeScopeOwner({ ownerSubject: 'current-owner' });
542
+ await storage.claimScope(current);
543
+ // Operator read an OLD snapshot (different owner) — e.g. a
544
+ // concurrent transfer landed between their read and this write.
545
+ const result = await storage.updateScopeOwner(makeScopeOwner({ ownerSubject: 'operator-target' }), { ownerSubject: 'stale-previous-owner', verification: 'unverified' });
546
+ expect(result).toEqual({ conflict: true });
547
+ expect(await storage.getScopeOwner('@test')).toEqual(current);
548
+ });
549
+ it('updateScopeOwner refuses a verification-stale snapshot', async () => {
550
+ const storage = makeStorage();
551
+ await storage.claimScope(makeScopeOwner());
552
+ // Verification flipped concurrently — the (owner, verification)
553
+ // pair is the snapshot identity, so a matching owner alone is
554
+ // not enough.
555
+ const result = await storage.updateScopeOwner(makeScopeOwner({ ownerSubject: 'new-owner' }), { ownerSubject: 'user-1', verification: 'verified' });
556
+ expect(result).toEqual({ conflict: true });
557
+ expect(await storage.getScopeOwner('@test')).toEqual(makeScopeOwner());
558
+ });
559
+ it('updateScopeOwner expect-absent refuses when a claim landed first', async () => {
560
+ const storage = makeStorage();
561
+ const claimed = makeScopeOwner({ ownerSubject: 'racing-claimant' });
562
+ await storage.claimScope(claimed);
563
+ const result = await storage.updateScopeOwner(makeScopeOwner({ ownerSubject: 'operator-target' }), { absent: true });
564
+ expect(result).toEqual({ conflict: true });
565
+ expect(await storage.getScopeOwner('@test')).toEqual(claimed);
566
+ });
567
+ it('updateScopeOwner expect-match refuses when the row is missing', async () => {
568
+ const storage = makeStorage();
569
+ const result = await storage.updateScopeOwner(makeScopeOwner(), {
570
+ ownerSubject: 'user-1',
571
+ verification: 'unverified',
572
+ });
573
+ expect(result).toEqual({ conflict: true });
574
+ expect(await storage.getScopeOwner('@test')).toBeNull();
575
+ });
576
+ it('getScopeOwner observes a completed claim immediately (conflict re-read obligation)', async () => {
577
+ // The publish gate's claim-conflict path re-reads to learn the
578
+ // winner. A read that can miss a completed claim would turn the
579
+ // losing racer's publish into a spurious storage-inconsistency
580
+ // failure — reads MUST be read-your-writes strong here.
581
+ const storage = makeStorage();
582
+ await storage.claimScope(makeScopeOwner({ ownerSubject: 'winner' }));
583
+ expect((await storage.getScopeOwner('@test'))?.ownerSubject).toBe('winner');
584
+ });
585
+ });
446
586
  describe('author keys', () => {
447
587
  it('returns null on miss', async () => {
448
588
  const storage = makeStorage();
@@ -462,6 +602,95 @@ export function registryStorageContract(makeStorage) {
462
602
  const aliceKeys = await storage.listAuthorKeys('alice');
463
603
  expect(aliceKeys.map((k) => k.keyId).sort()).toEqual(['k1', 'k2']);
464
604
  });
605
+ it('round-trips a legacy row without enrichment fields', async () => {
606
+ // Rows written before the [E] enrichment carry neither
607
+ // createdAt nor label — impls MUST NOT fabricate either.
608
+ const storage = makeStorage();
609
+ const row = {
610
+ subject: 'legacy-subject',
611
+ keyId: 'legacy-key',
612
+ publicKeyBase64: 'CCCC',
613
+ };
614
+ await storage.putAuthorKey(row);
615
+ const fetched = await storage.getAuthorKey('legacy-subject', 'legacy-key');
616
+ expect(fetched).toEqual(row);
617
+ expect(fetched?.createdAt).toBeUndefined();
618
+ expect(fetched?.label).toBeUndefined();
619
+ });
620
+ it('round-trips base64url-alphabet keyIds (- and _)', async () => {
621
+ // derivePublicKeyId emits RFC 4648 §5 base64url — the '-'/'_'
622
+ // characters are representative of the real id alphabet and
623
+ // MUST survive every impl's row-key encoding.
624
+ const storage = makeStorage();
625
+ const row = makeAuthorKey({ keyId: 'aB-cD_eF-gH_iJ-k' });
626
+ await storage.putAuthorKey(row);
627
+ expect(await storage.getAuthorKey(row.subject, row.keyId)).toEqual(row);
628
+ const listed = await storage.listAuthorKeys(row.subject);
629
+ expect(listed.map((k) => k.keyId)).toContain('aB-cD_eF-gH_iJ-k');
630
+ expect(await storage.deleteAuthorKey(row.subject, row.keyId)).toBe(true);
631
+ expect(await storage.getAuthorKey(row.subject, row.keyId)).toBe(null);
632
+ });
633
+ it("isolates subjects containing '/' — composite-key encodings must be unambiguous", async () => {
634
+ // Subjects are operator-defined free text; a naive
635
+ // `${subject}/${keyId}` composite (or a raw path component)
636
+ // makes 'team' and 'team/alice' collide or leak.
637
+ const storage = makeStorage();
638
+ await storage.putAuthorKey(makeAuthorKey({ subject: 'team/alice', keyId: 'k1' }));
639
+ await storage.putAuthorKey(makeAuthorKey({ subject: 'team', keyId: 'k2' }));
640
+ const teamKeys = await storage.listAuthorKeys('team');
641
+ expect(teamKeys.map((k) => k.keyId)).toEqual(['k2']);
642
+ const nestedKeys = await storage.listAuthorKeys('team/alice');
643
+ expect(nestedKeys.map((k) => k.keyId)).toEqual(['k1']);
644
+ // Delete under 'team' must not touch team/alice's row.
645
+ expect(await storage.deleteAuthorKey('team', 'k1')).toBe(false);
646
+ expect(await storage.getAuthorKey('team/alice', 'k1')).not.toBe(null);
647
+ });
648
+ it('putAuthorKey with ifNotExists rejects an existing row with AuthorKeyAlreadyExistsError', async () => {
649
+ // The register op's TOCTOU close relies on this conditional
650
+ // rejecting — an impl that silently upserts turns idempotent
651
+ // re-registers into false 201s.
652
+ const storage = makeStorage();
653
+ const row = makeAuthorKey();
654
+ await storage.putAuthorKey(row, { ifNotExists: true });
655
+ await expect(storage.putAuthorKey(makeAuthorKey({ publicKeyBase64: 'ZZZZ' }), { ifNotExists: true })).rejects.toThrow(AuthorKeyAlreadyExistsError);
656
+ // The stored row is untouched by the rejected write.
657
+ expect(await storage.getAuthorKey(row.subject, row.keyId)).toEqual(row);
658
+ });
659
+ it('putAuthorKey without options is an unconditional upsert', async () => {
660
+ const storage = makeStorage();
661
+ await storage.putAuthorKey(makeAuthorKey({ label: 'first' }));
662
+ await storage.putAuthorKey(makeAuthorKey({ label: 'second' }));
663
+ const row = await storage.getAuthorKey('user-1', 'key-1');
664
+ expect(row?.label).toBe('second');
665
+ });
666
+ it('deletes an existing author key and reports deleted: true', async () => {
667
+ const storage = makeStorage();
668
+ const row = makeAuthorKey();
669
+ await storage.putAuthorKey(row);
670
+ expect(await storage.deleteAuthorKey(row.subject, row.keyId)).toBe(true);
671
+ expect(await storage.getAuthorKey(row.subject, row.keyId)).toBe(null);
672
+ expect(await storage.listAuthorKeys(row.subject)).toEqual([]);
673
+ });
674
+ it('delete of an absent key reports deleted: false (idempotent)', async () => {
675
+ const storage = makeStorage();
676
+ expect(await storage.deleteAuthorKey('nobody', 'nope')).toBe(false);
677
+ // Second delete of a just-deleted row is the same absent case.
678
+ const row = makeAuthorKey();
679
+ await storage.putAuthorKey(row);
680
+ await storage.deleteAuthorKey(row.subject, row.keyId);
681
+ expect(await storage.deleteAuthorKey(row.subject, row.keyId)).toBe(false);
682
+ });
683
+ it('delete is isolated per subject — another subject\'s same keyId survives', async () => {
684
+ const storage = makeStorage();
685
+ await storage.putAuthorKey(makeAuthorKey({ subject: 'alice', keyId: 'k1' }));
686
+ await storage.putAuthorKey(makeAuthorKey({ subject: 'bob', keyId: 'k1' }));
687
+ // Deleting under a subject that has no such key touches nothing.
688
+ expect(await storage.deleteAuthorKey('charlie', 'k1')).toBe(false);
689
+ // Alice's delete removes ONLY alice's row.
690
+ expect(await storage.deleteAuthorKey('alice', 'k1')).toBe(true);
691
+ expect(await storage.getAuthorKey('alice', 'k1')).toBe(null);
692
+ expect(await storage.getAuthorKey('bob', 'k1')).not.toBe(null);
693
+ });
465
694
  });
466
695
  });
467
696
  }