@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.
- package/dist/impls/memory-registry-storage.d.ts.map +1 -1
- package/dist/impls/memory-registry-storage.js +44 -4
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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/registry-storage.d.ts +95 -1
- package/dist/interfaces/registry-storage.d.ts.map +1 -1
- 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/publish.d.ts +38 -1
- package/dist/ops/publish.d.ts.map +1 -1
- package/dist/ops/publish.js +145 -3
- 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/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 +229 -0
- package/dist/types.d.ts +143 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +49 -0
- package/package.json +13 -4
package/dist/ops/publish.js
CHANGED
|
@@ -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
|
|
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;
|
|
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
|
|
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,
|
|
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
|
}
|