@ggui-ai/registry-core 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/impls/memory-bundle-storage.d.ts.map +1 -1
  2. package/dist/impls/memory-bundle-storage.js +25 -22
  3. package/dist/impls/memory-registry-storage.d.ts.map +1 -1
  4. package/dist/impls/memory-registry-storage.js +47 -4
  5. package/dist/index.d.ts +8 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +8 -2
  8. package/dist/install-command.d.ts +25 -0
  9. package/dist/install-command.d.ts.map +1 -0
  10. package/dist/install-command.js +15 -0
  11. package/dist/interfaces/bundle-storage.d.ts +55 -31
  12. package/dist/interfaces/bundle-storage.d.ts.map +1 -1
  13. package/dist/interfaces/registry-storage.d.ts +106 -1
  14. package/dist/interfaces/registry-storage.d.ts.map +1 -1
  15. package/dist/mcp-tool-filters.d.ts +23 -0
  16. package/dist/mcp-tool-filters.d.ts.map +1 -0
  17. package/dist/mcp-tool-filters.js +14 -0
  18. package/dist/ops/delete-author-key.d.ts +45 -0
  19. package/dist/ops/delete-author-key.d.ts.map +1 -0
  20. package/dist/ops/delete-author-key.js +31 -0
  21. package/dist/ops/list-author-keys.d.ts +34 -0
  22. package/dist/ops/list-author-keys.d.ts.map +1 -0
  23. package/dist/ops/list-author-keys.js +45 -0
  24. package/dist/ops/list-versions.d.ts +19 -10
  25. package/dist/ops/list-versions.d.ts.map +1 -1
  26. package/dist/ops/list-versions.js +43 -8
  27. package/dist/ops/private-read-authz.d.ts +88 -0
  28. package/dist/ops/private-read-authz.d.ts.map +1 -0
  29. package/dist/ops/private-read-authz.js +58 -0
  30. package/dist/ops/publish.d.ts +68 -1
  31. package/dist/ops/publish.d.ts.map +1 -1
  32. package/dist/ops/publish.js +292 -16
  33. package/dist/ops/read.d.ts +28 -6
  34. package/dist/ops/read.d.ts.map +1 -1
  35. package/dist/ops/read.js +47 -7
  36. package/dist/ops/register-author-key.d.ts +13 -0
  37. package/dist/ops/register-author-key.d.ts.map +1 -1
  38. package/dist/ops/register-author-key.js +42 -0
  39. package/dist/ops/search.d.ts +13 -1
  40. package/dist/ops/search.d.ts.map +1 -1
  41. package/dist/ops/search.js +54 -2
  42. package/dist/testing/bundle-storage-contract.d.ts.map +1 -1
  43. package/dist/testing/bundle-storage-contract.js +61 -27
  44. package/dist/testing/registry-storage-contract.d.ts +1 -1
  45. package/dist/testing/registry-storage-contract.d.ts.map +1 -1
  46. package/dist/testing/registry-storage-contract.js +351 -1
  47. package/dist/types.d.ts +257 -17
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/types.js +79 -14
  50. package/package.json +20 -5
@@ -6,6 +6,33 @@
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 when a LATER gate (bundle, conformance,
22
+ * crypto verify) fails this publish: by then the caller passed
23
+ * every policy gate, so the claim records legitimate intent, a
24
+ * failed-publish claim stays re-usable by the same caller, and
25
+ * an unverified claim remains reclaimable by the registry
26
+ * operator. Deliberately the simplest correct behavior.
27
+ * 2d. Bind the publisher identity (F4, sigstore-signed publishes
28
+ * only): the bundle certificate's SAN must be on the scope's
29
+ * `sanAllowlist` when the ownership row carries one, else must
30
+ * equal the account's verified email when the deployment wires
31
+ * {@link PublishArtifactDeps.verifiedEmailResolver}. Neither
32
+ * configured ⇒ no identity rule (allowlist-only posture).
33
+ * Violations answer 403 `identity_mismatch`. On the UNCLAIMED
34
+ * path this gate runs BEFORE the claim — a rejected signer
35
+ * must not walk away owning the scope.
9
36
  * 3. Decode + size-check the bundle (gadgets only).
10
37
  * 4. Recompute SHA-384 of the bundle bytes; compare to client claim.
11
38
  * 5. Re-run the conformance gate ({@link checkConformance}).
@@ -21,11 +48,11 @@
21
48
  * `latestVersion` when the new version is the highest semver.
22
49
  * 10. Return 201 with the wire-locked {@link PublishResponseBody}.
23
50
  */
24
- import { parseArtifactManifest, } from '@ggui-ai/artifact-manifest';
25
- import { canonicalJson, extractSigstoreLeafCertPem, isGadgetSignature, verifyBundleEd25519, verifyBundleSigstore, } from '@ggui-ai/gadget-signing';
26
- import { bundleHostScheme } from '@ggui-ai/protocol';
51
+ import { manifestToRegistryEntry, parseArtifactManifest, resolveMcpToolBindings, } from '@ggui-ai/artifact-manifest';
52
+ import { canonicalJson, extractSigstoreLeafCertPem, extractSigstoreSANs, isGadgetSignature, verifyBundleEd25519, verifyBundleSigstore, } from '@ggui-ai/gadget-signing';
53
+ import { bundleHostScheme, strictGadgetDescriptorSchema } from '@ggui-ai/protocol';
27
54
  import { ZodError } from 'zod';
28
- import { ARTIFACTS_METADATA_SK } from '../types.js';
55
+ import { ARTIFACTS_METADATA_SK, SAN_ALLOWLIST_INVALID } from '../types.js';
29
56
  import { safeBase64Decode, sha384Base64 } from '../utils/base64.js';
30
57
  import { compareSemver } from '../utils/semver.js';
31
58
  import { compileBlueprint } from './compile.js';
@@ -38,6 +65,36 @@ import { checkConformance } from './conformance.js';
38
65
  * against an OSS-mirrored registry without re-bundling).
39
66
  */
40
67
  export const MAX_BUNDLE_BYTES = 5 * 1024 * 1024;
68
+ /**
69
+ * Scopes no publish may CLAIM — well-known names whose squatting would
70
+ * mislead installers about who authored an artifact. The default
71
+ * covers first-party names plus obvious squat-bait; a deployment
72
+ * REPLACES the whole list via
73
+ * {@link PublishArtifactDeps.reservedScopes} (spread this constant to
74
+ * extend it instead).
75
+ *
76
+ * Reserved scopes block first-publish CLAIMS only — not owned
77
+ * publishes. A registry operator who wants artifacts under a reserved
78
+ * name seeds its ownership row out-of-band via
79
+ * {@link RegistryStorage.updateScopeOwner}; once the row exists, the
80
+ * normal owner rule applies (the seeded owner publishes, everyone else
81
+ * gets `scope_forbidden`) with no change to this list.
82
+ */
83
+ export const RESERVED_SCOPES = [
84
+ '@ggui-ai',
85
+ '@ggui',
86
+ '@guuey',
87
+ '@anthropic',
88
+ '@claude',
89
+ '@openai',
90
+ '@google',
91
+ '@gemini',
92
+ '@meta',
93
+ '@microsoft',
94
+ '@aws',
95
+ '@amazon',
96
+ '@apple',
97
+ ];
41
98
  export async function publishArtifact(input, deps) {
42
99
  // 1. Authn — transport enforces before calling, but defensive read.
43
100
  if (typeof deps.authn.subject !== 'string' || deps.authn.subject.length === 0) {
@@ -66,6 +123,184 @@ export async function publishArtifact(input, deps) {
66
123
  }
67
124
  const artifactId = `${manifest.scope}/${manifest.name}`;
68
125
  const version = manifest.version;
126
+ // 2b. Visibility ↔ signature-algorithm pairing. The two algorithms
127
+ // carry different trust models: sigstore keyless signing records
128
+ // every publish in a public transparency log — the third-party
129
+ // auditability that makes an artifact safe to list publicly — while
130
+ // an Ed25519 author key leaves no public record, which is the point
131
+ // for private artifacts. Clients pair them at signing time, but the
132
+ // server cannot trust a hand-rolled request: an unenforced
133
+ // public+Ed25519 publish would become publicly listable with no
134
+ // transparency-log entry. Cheap field comparison — runs before any
135
+ // bundle decode, conformance, or cryptographic verify work.
136
+ if (manifest.visibility === 'public' && input.signature.algorithm === 'ed25519') {
137
+ 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.");
138
+ }
139
+ if (manifest.visibility === 'private' && input.signature.algorithm === 'sigstore-cosign') {
140
+ 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.");
141
+ }
142
+ // 2c. Scope ownership. Runs after the pairing check (2b) and before
143
+ // any bundle decode — ownership is a cheap read and a forbidden
144
+ // publish must not pay for (or leak errors from) bundle work.
145
+ //
146
+ // Order within the gate: owner lookup first, then — only when the
147
+ // scope is UNCLAIMED — the reserved denylist, then the first-publish
148
+ // claim. Reserved scopes block CLAIMS, not owned publishes: a scope
149
+ // whose ownership row exists (seeded by the registry operator, or
150
+ // grandfathered) follows the normal owner rule, so the operator can
151
+ // publish first-party artifacts under a reserved name without
152
+ // touching the reserved list. A lost claim race re-reads and
153
+ // re-applies the owner check — the conditional create in
154
+ // `claimScope` is the only race-safe primitive, so `conflict` means
155
+ // somebody else's row is now durable and the re-read decides whose
156
+ // scope this is.
157
+ //
158
+ // The claim is DURABLE when a LATER gate (bundle, conformance,
159
+ // crypto verify) fails this publish (documented judgment call — see
160
+ // the flow docstring): by then the caller has already passed every
161
+ // policy gate, so the claim records legitimate intent. The identity
162
+ // gate (2d) is different — it runs BEFORE the claim on the unclaimed
163
+ // path, because a signer the scope's identity rule rejects must not
164
+ // walk away owning the scope. That ordering costs nothing: a fresh
165
+ // claim can never carry a `sanAllowlist`, so only the deployment's
166
+ // default email rule can apply pre-claim. The claim still lands
167
+ // before cryptographic signature verification: moving it after would
168
+ // not stop a motivated squatter (any authenticated caller can
169
+ // produce a validly-signed private publish), so the real defenses
170
+ // against mass squatting are the operator reclaim flow, the audit
171
+ // trail, and (future) rate limiting.
172
+ 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.`);
173
+ // 2d. Publisher-identity binding (F4) — sigstore-signed publishes
174
+ // only (returns `undefined` = pass for Ed25519). Binds the bundle's
175
+ // certificate identity (the Fulcio cert's SAN) to the ggui account
176
+ // that owns (or is claiming) the scope, so a valid-but-unrelated
177
+ // OIDC identity can no longer sign publishes into it. Two rules,
178
+ // strictly ordered:
179
+ //
180
+ // 1. ALLOWLIST — the ownership row carries a non-empty
181
+ // `sanAllowlist`: the SAN must be one of its literal entries
182
+ // (operator-managed; org/CI flexibility).
183
+ // 2. VERIFIED EMAIL (default) — no allowlist, and the deployment
184
+ // wires {@link PublishArtifactDeps.verifiedEmailResolver}: the
185
+ // SAN must equal the account's verified email
186
+ // (case-insensitive). An account without a verified email
187
+ // fails closed.
188
+ //
189
+ // No allowlist AND no resolver ⇒ no identity rule — the deployment
190
+ // enforces identity only through allowlists (see the resolver
191
+ // docstring for why that posture is documented rather than papered
192
+ // over).
193
+ //
194
+ // Invocation points (all before bundle decode or cryptographic
195
+ // verify — the SAN is a cheap local projection, so a forbidden
196
+ // identity never pays for, or leaks errors from, bundle work):
197
+ // - claimed scope: with the stored ownership row;
198
+ // - unclaimed scope: with `null` BEFORE the claim (fresh claims
199
+ // never carry an allowlist — email rule only);
200
+ // - lost claim race won by the same subject: re-run with the
201
+ // winner row, which MAY be operator-seeded with an allowlist.
202
+ // The projection is trustworthy in the reject direction
203
+ // unconditionally; in the accept direction it is paired with the
204
+ // REAL `verifyBundleSigstore` at step 6, which proves the same
205
+ // SAN-bearing cert is genuinely CA-issued and bound to the signed
206
+ // bytes (same parser both places — the projection cannot drift from
207
+ // what verification enforces).
208
+ //
209
+ // Error hygiene: messages name the rule that failed and MAY echo the
210
+ // caller's OWN certificate identity (they supplied it), but NEVER
211
+ // other identities — not allowlist entries, not the account email.
212
+ const checkPublisherIdentity = async (ownerRow) => {
213
+ if (input.signature.algorithm !== 'sigstore-cosign')
214
+ return undefined;
215
+ // Fail closed on corrupt policy data: a storage adapter projects a
216
+ // malformed allowlist column as SAN_ALLOWLIST_INVALID (see the
217
+ // ScopeOwnerRow docstring). Falling through to the email rule (or
218
+ // no rule) would let corruption silently WIDEN who may publish.
219
+ const rawAllowlist = ownerRow?.sanAllowlist;
220
+ if (rawAllowlist === SAN_ALLOWLIST_INVALID) {
221
+ return error(500, 'internal', `scope \`${manifest.scope}\` has a malformed publisher-identity allowlist in storage — refusing to fall back to a weaker identity rule. A registry operator must rewrite the scope's allowlist (set or clear it) before sigstore publishes into this scope can proceed.`);
222
+ }
223
+ // An empty allowlist behaves like an absent one — the allowlist
224
+ // rule applies only when at least one entry names a signer.
225
+ const sanAllowlist = rawAllowlist ?? [];
226
+ const resolver = deps.verifiedEmailResolver;
227
+ if (sanAllowlist.length === 0 && resolver === undefined)
228
+ return undefined;
229
+ // EVERY SAN on the certificate (a Fulcio cert can carry both a
230
+ // URI and an email SAN) — the rules below accept on ANY hit, so a
231
+ // both-SAN cert matches whichever identity the policy names.
232
+ const sans = extractSigstoreSANs(input.signature).map((san) => san.toLowerCase());
233
+ if (sans.length === 0) {
234
+ return error(403, 'identity_mismatch', `scope \`${manifest.scope}\` requires a bound publisher identity, but the sigstore bundle's certificate carries no subject identity (SAN) to check`);
235
+ }
236
+ const echoedSans = sans.map((san) => `\`${san}\``).join(', ');
237
+ if (sanAllowlist.length > 0) {
238
+ // ONE case rule for every identity comparison (mirrors the email
239
+ // rule below): operator tooling lowercase-normalizes entries at
240
+ // write, and the comparison is case-insensitive regardless so
241
+ // rows seeded by other paths behave identically.
242
+ const allowlistLower = sanAllowlist.map((entry) => entry.toLowerCase());
243
+ if (!sans.some((san) => allowlistLower.includes(san))) {
244
+ return error(403, 'identity_mismatch', `no certificate identity (${echoedSans}) is on the publisher-identity allowlist for scope \`${manifest.scope}\`. Sign with an allowlisted identity, or ask the registry operator to update the scope's allowlist.`);
245
+ }
246
+ return undefined;
247
+ }
248
+ if (resolver !== undefined) {
249
+ const verifiedEmail = await resolver(deps.authn.subject);
250
+ if (verifiedEmail === undefined) {
251
+ return error(403, 'identity_mismatch', `scope \`${manifest.scope}\` binds publishes to the account's verified email, but the publishing account has none. Verify your account email, or ask the registry operator to set a publisher-identity allowlist for the scope. If you verified your email just now, it can take a minute to propagate — retry shortly.`);
252
+ }
253
+ if (!sans.includes(verifiedEmail.toLowerCase())) {
254
+ return error(403, 'identity_mismatch', `no certificate identity (${echoedSans}) matches the publishing account's verified email. Sign with the OIDC identity of your account email, or ask the registry operator to add this identity to the scope's publisher allowlist.`);
255
+ }
256
+ }
257
+ return undefined;
258
+ };
259
+ const existingOwner = await deps.storage.getScopeOwner(manifest.scope);
260
+ if (existingOwner !== null && existingOwner.ownerSubject !== deps.authn.subject) {
261
+ return scopeForbiddenByOwner();
262
+ }
263
+ if (existingOwner !== null) {
264
+ const identityFailure = await checkPublisherIdentity(existingOwner);
265
+ if (identityFailure !== undefined)
266
+ return identityFailure;
267
+ }
268
+ else {
269
+ const reservedScopes = deps.reservedScopes ?? RESERVED_SCOPES;
270
+ if (reservedScopes.includes(manifest.scope)) {
271
+ 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.`);
272
+ }
273
+ // Identity BEFORE the claim (review r1 finding 2): a rejected
274
+ // signer must not walk away owning the scope. Fresh claims carry
275
+ // no allowlist, so this pre-claim run applies the email rule only.
276
+ const identityFailure = await checkPublisherIdentity(null);
277
+ if (identityFailure !== undefined)
278
+ return identityFailure;
279
+ const claim = await deps.storage.claimScope({
280
+ scope: manifest.scope,
281
+ ownerSubject: deps.authn.subject,
282
+ claimedAt: deps.clock().toISOString(),
283
+ verification: 'unverified',
284
+ });
285
+ if ('conflict' in claim) {
286
+ // Lost the race — re-read and re-apply the owner check.
287
+ const winner = await deps.storage.getScopeOwner(manifest.scope);
288
+ if (winner === null) {
289
+ // claimScope reported an existing row but the re-read found
290
+ // none — storage-layer inconsistency, not a policy outcome.
291
+ return error(500, 'internal', `scope claim for \`${manifest.scope}\` conflicted but no ownership row exists — storage inconsistency`);
292
+ }
293
+ if (winner.ownerSubject !== deps.authn.subject) {
294
+ return scopeForbiddenByOwner();
295
+ }
296
+ // The winner row may be operator-seeded WITH an allowlist the
297
+ // pre-claim run (against `null`) never saw — re-apply the gate
298
+ // against the durable row.
299
+ const raceIdentityFailure = await checkPublisherIdentity(winner);
300
+ if (raceIdentityFailure !== undefined)
301
+ return raceIdentityFailure;
302
+ }
303
+ }
69
304
  // 3. Bundle decode + size (gadgets only)
70
305
  let bundleBytes;
71
306
  if (manifest.kind === 'gadget') {
@@ -88,6 +323,31 @@ export async function publishArtifact(input, deps) {
88
323
  if (recomputed !== input.bundleSha384) {
89
324
  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
325
  }
326
+ // 4b. Projection viability — the install path projects this
327
+ // manifest into a catalog row (`manifestToRegistryEntry` →
328
+ // `strictGadgetDescriptorSchema`). The two schemas are separate
329
+ // validators (e.g. manifest `connect[]` entries are free-form
330
+ // strings while catalog `connect[]` entries must be full URLs),
331
+ // and a published version is immutable — a manifest that projects
332
+ // to an invalid row would be PERMANENTLY uninstallable. Reject it
333
+ // now, naming the offending field, while the author can still fix
334
+ // and republish.
335
+ const projected = manifestToRegistryEntry(manifest, {
336
+ version: manifest.version,
337
+ // Representative install-time computed fields: the bundle URL is
338
+ // stamped by storage later, so a syntactically valid placeholder
339
+ // stands in; the SRI is the real digest verified above.
340
+ bundleUrl: 'https://registry.invalid/bundle.js',
341
+ bundleSri: `sha384-${recomputed}`,
342
+ });
343
+ const projectionCheck = strictGadgetDescriptorSchema.safeParse(projected);
344
+ if (!projectionCheck.success) {
345
+ const first = projectionCheck.error.issues[0];
346
+ const path = (first?.path ?? [])
347
+ .map((seg) => String(seg))
348
+ .join('.');
349
+ 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 });
350
+ }
91
351
  }
92
352
  // 5. Conformance gate
93
353
  const conformanceBundleText = bundleBytes === undefined
@@ -141,15 +401,19 @@ export async function publishArtifact(input, deps) {
141
401
  }
142
402
  else {
143
403
  // Public gadgets — sigstore (Fulcio + Rekor) trust chain.
144
- // Identity claim: trust ANY valid OIDC identity at publish-time —
145
- // the publisher is already authenticated by the transport layer
146
- // ahead of this op. Install-time consumers CAN tighten via
147
- // `--verify-identity <pattern>` (CLI install flag); that's a
148
- // separate trust decision controlled by the install operator, not
149
- // the publisher.
404
+ // Identity claim: gate 2d already bound the certificate's SAN to
405
+ // the scope's allowlist / the account's verified email (where
406
+ // configured — see the gate for the honest no-rule posture). This
407
+ // verify is the cryptographic half of that pairing: it proves the
408
+ // SAN-bearing cert is genuinely CA-issued, transparency-logged,
409
+ // and bound to the signed bytes. Install-time consumers can layer
410
+ // their own policy via `--verify-identity <pattern>` (CLI install
411
+ // flag) — a separate trust decision controlled by the install
412
+ // operator, not the publisher.
150
413
  const verifyResult = await verifyBundleSigstore({
151
414
  bundleBytes: bytesForSignature,
152
415
  signature: input.signature,
416
+ ...(deps.sigstoreTuf ?? {}),
153
417
  });
154
418
  if (!verifyResult.valid) {
155
419
  return error(400, 'signature_invalid', verifyResult.reason);
@@ -160,7 +424,7 @@ export async function publishArtifact(input, deps) {
160
424
  // impl (single source of truth for the bundle shape).
161
425
  const leafCertPem = extractSigstoreLeafCertPem(input.signature);
162
426
  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');
427
+ 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
428
  }
165
429
  authorPublicKey = leafCertPem;
166
430
  }
@@ -222,12 +486,17 @@ export async function publishArtifact(input, deps) {
222
486
  // `version_exists` immediately — the publisher's idempotent retry path.
223
487
  const nowIso = deps.clock().toISOString();
224
488
  const sriHash = bundleBytes === undefined ? undefined : `sha384-${sha384Base64(bundleBytes)}`;
489
+ // H1 prefix split — the manifest's visibility selects the blob
490
+ // placement (`bundles/public/…` vs `bundles/private/…`), and the
491
+ // persisted row URLs reflect that real location: public URLs stay
492
+ // CDN/static-servable, private URLs resolve only through the
493
+ // authenticated private-bundle route.
225
494
  const bundleUrl = bundleBytes === undefined
226
495
  ? undefined
227
- : deps.bundleStorage.bundleUrl(manifest.scope, manifest.name, version);
496
+ : deps.bundleStorage.bundleUrl(manifest.scope, manifest.name, version, manifest.visibility);
228
497
  const signatureUrl = bundleBytes === undefined
229
498
  ? undefined
230
- : deps.bundleStorage.signatureUrl(manifest.scope, manifest.name, version);
499
+ : deps.bundleStorage.signatureUrl(manifest.scope, manifest.name, version, manifest.visibility);
231
500
  const versionRow = {
232
501
  artifactId,
233
502
  version,
@@ -266,10 +535,10 @@ export async function publishArtifact(input, deps) {
266
535
  let manifestUrl;
267
536
  try {
268
537
  if (bundleBytes !== undefined) {
269
- await deps.bundleStorage.putBundle(manifest.scope, manifest.name, version, bundleBytes);
270
- await deps.bundleStorage.putSignature(manifest.scope, manifest.name, version, input.signature);
538
+ await deps.bundleStorage.putBundle(manifest.scope, manifest.name, version, manifest.visibility, bundleBytes);
539
+ await deps.bundleStorage.putSignature(manifest.scope, manifest.name, version, manifest.visibility, input.signature);
271
540
  }
272
- manifestUrl = await deps.bundleStorage.putManifest(manifest.scope, manifest.name, version, manifest);
541
+ manifestUrl = await deps.bundleStorage.putManifest(manifest.scope, manifest.name, version, manifest.visibility, manifest);
273
542
  }
274
543
  catch (err) {
275
544
  return error(500, 'internal', `failed to upload artifact: ${errorMessage(err)}`);
@@ -287,6 +556,11 @@ export async function publishArtifact(input, deps) {
287
556
  // export name. The package may export several hooks/components;
288
557
  // the manifest's `exports[]` is the source of truth.
289
558
  const primaryExport = manifest.kind === 'gadget' ? manifest.exports[0] : undefined;
559
+ // Denormalized search field — the effective MCP tool bindings
560
+ // (declared wins entirely; blueprints derive from their contract).
561
+ // Search metadata ONLY: never part of contract canonicalization,
562
+ // blueprintKey, or any cache identity.
563
+ const bindingResolution = resolveMcpToolBindings(manifest);
290
564
  const metadataRow = {
291
565
  artifactId,
292
566
  sk: ARTIFACTS_METADATA_SK,
@@ -300,6 +574,8 @@ export async function publishArtifact(input, deps) {
300
574
  : 'hook' in primaryExport
301
575
  ? primaryExport.hook
302
576
  : primaryExport.component,
577
+ mcpTools: bindingResolution?.bindings,
578
+ mcpToolsSource: bindingResolution?.source,
303
579
  authorName: manifest.author?.name,
304
580
  publishedAt: nowIso,
305
581
  publishedBy: deps.authn.subject,
@@ -5,14 +5,34 @@
5
5
  *
6
6
  * Flow:
7
7
  * 1. Point-read the version row via {@link RegistryStorage.getArtifactVersion}.
8
- * 2. Enforce row `visibility`: `private` → require an {@link AuthnContext}.
8
+ * 2. Enforce row `visibility`: `private` → the caller must satisfy
9
+ * {@link canReadPrivateArtifact} (publisher or scope owner).
10
+ * Anyone else receives a response byte-identical to a true
11
+ * not-found — no status, code, or message distinguishes "exists
12
+ * but you may not read it" from "does not exist".
9
13
  * 3. Yanked → 410 with the manifest still in the body (audit-friendly).
14
+ * The private gate fires FIRST — an unauthorized caller never sees
15
+ * the 410 body.
10
16
  * 4. For blueprints, resolve the {@link CompiledBlobRow} via
11
17
  * `row.compiledDigest` (two-layer content-addressed storage). A
12
18
  * missing blob when the pointer is set is a critical storage
13
19
  * inconsistency — the op returns 500 rather than silently
14
20
  * omitting `compiledBytes`.
15
- * 5. Project the row + blob into the {@link ReadPkgResponse} wire shape.
21
+ * 5. Project the row + blob into the {@link ReadPkgResponse} wire shape,
22
+ * including `scopeVerification`/`verifiedDomain`.
23
+ *
24
+ * Scope-owner reads (2026-08-10, MCP discovery §2): every read — public
25
+ * or private — now costs exactly ONE memoized {@link RegistryStorage.getScopeOwner}
26
+ * point-read via {@link createScopeOwnerResolver}, shared between the
27
+ * private-read gate (when applicable) and the `scopeVerification` /
28
+ * `verifiedDomain` wire projection. This deliberately SUPERSEDES the
29
+ * prior "public reads cost zero extra storage reads" guarantee from
30
+ * the private-read-ownership work ({@link ./private-read-authz.js}) —
31
+ * verification surfacing needs the scope row on every read, public
32
+ * included, so the old zero-reads pin was retired in favor of a
33
+ * one-read invariant instead. A failed or missing scope-owner read
34
+ * degrades to absent `scopeVerification`/`verifiedDomain` (never
35
+ * defaulted to `'unverified'`) and never fails the read itself.
16
36
  */
17
37
  import type { ReadErrorBody, ReadPkgResponse } from '../types.js';
18
38
  import type { AuthnContext } from '../interfaces/authn.js';
@@ -29,9 +49,11 @@ export interface ReadArtifactInput {
29
49
  export interface ReadArtifactDeps {
30
50
  readonly storage: RegistryStorage;
31
51
  /**
32
- * Optional — when undefined, the op rejects `private` rows with 403.
33
- * Cloud transport extracts from the JWT authorizer; OSS extracts from
34
- * the bearer authn middleware.
52
+ * Optional — the verified caller context, produced by the
53
+ * transport's own credential verification. When the requested row
54
+ * is `private`, the op admits only the row's publisher or the
55
+ * artifact's scope owner ({@link canReadPrivateArtifact}); every
56
+ * other caller — anonymous included — gets the not-found shape.
35
57
  */
36
58
  readonly authn?: AuthnContext;
37
59
  }
@@ -45,7 +67,7 @@ export type ReadArtifactResult = {
45
67
  readonly body: ReadPkgResponse;
46
68
  } | {
47
69
  readonly ok: false;
48
- readonly status: 400 | 403 | 404 | 500;
70
+ readonly status: 400 | 404 | 500;
49
71
  readonly body: ReadErrorBody;
50
72
  };
51
73
  export declare function readArtifact(input: ReadArtifactInput, deps: ReadArtifactDeps): Promise<ReadArtifactResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../src/ops/read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAGV,aAAa,EAEb,eAAe,EAChB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAEzE,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;CAC/B;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,GAC3E;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,GAC5E;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACvC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;CAC9B,CAAC;AAEN,wBAAsB,YAAY,CAChC,KAAK,EAAE,iBAAiB,EACxB,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,kBAAkB,CAAC,CAkE7B"}
1
+ {"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../src/ops/read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,OAAO,KAAK,EAGV,aAAa,EAEb,eAAe,EAEhB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAMzE,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;CAC/B;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,GAC3E;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,GAC5E;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;CAC9B,CAAC;AAEN,wBAAsB,YAAY,CAChC,KAAK,EAAE,iBAAiB,EACxB,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,kBAAkB,CAAC,CA0E7B"}
package/dist/ops/read.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { canReadPrivateArtifact, createScopeOwnerResolver, } from './private-read-authz.js';
1
2
  export async function readArtifact(input, deps) {
2
3
  if (typeof input.artifactId !== 'string' || input.artifactId.length === 0) {
3
4
  return errorResult(400, 'invalid_request', 'missing artifactId');
@@ -5,18 +6,33 @@ export async function readArtifact(input, deps) {
5
6
  if (typeof input.version !== 'string' || input.version.length === 0) {
6
7
  return errorResult(400, 'invalid_request', 'missing version');
7
8
  }
9
+ // Scope-owner resolver — memoized, so the private-read gate and the
10
+ // verification surfacing share at most ONE storage read. Fail-closed:
11
+ // a storage fault resolves null, which denies on the private gate and
12
+ // omits the verification fields (state unknown is not 'unverified').
13
+ const resolveScopeOwner = createScopeOwnerResolver(deps.storage, input.artifactId);
8
14
  let row;
9
15
  try {
10
16
  row = await deps.storage.getArtifactVersion(input.artifactId, input.version);
11
17
  }
12
18
  catch (err) {
13
- return errorResult(500, 'server_error', `failed to read package: ${err instanceof Error ? err.message : String(err)}`);
19
+ logStorageFailure('read version row', input.artifactId, err);
20
+ return errorResult(500, 'server_error', 'failed to read package');
14
21
  }
15
22
  if (row === null) {
16
- return errorResult(404, 'not_found', `package ${input.artifactId}@${input.version} not found`);
23
+ return notFoundResult(input);
17
24
  }
18
- if (row.visibility === 'private' && deps.authn === undefined) {
19
- return errorResult(403, 'forbidden', 'private package requires authentication');
25
+ if (row.visibility === 'private') {
26
+ // Ownership gate — publisher or scope owner only. The
27
+ // unauthorized response is produced by the SAME function as a
28
+ // true miss so the two are structurally indistinguishable. The
29
+ // resolver is fail-closed: a storage fault during the owner
30
+ // lookup denies (logged server-side) rather than erroring — a
31
+ // 500 only private rows could trigger would leak existence.
32
+ const allowed = await canReadPrivateArtifact(deps.authn, row, resolveScopeOwner);
33
+ if (!allowed) {
34
+ return notFoundResult(input);
35
+ }
20
36
  }
21
37
  // Two-layer resolution — blueprint rows carry a pointer into the
22
38
  // compiled-blob table. Yanked rows still resolve so the 410
@@ -28,7 +44,8 @@ export async function readArtifact(input, deps) {
28
44
  compiledBlob = await deps.storage.getCompiledBlob(row.compiledDigest);
29
45
  }
30
46
  catch (err) {
31
- return errorResult(500, 'server_error', `failed to read compiled blob for compiledDigest=${row.compiledDigest}: ${err instanceof Error ? err.message : String(err)}`);
47
+ logStorageFailure('read compiled blob', input.artifactId, err);
48
+ return errorResult(500, 'server_error', `failed to read compiled blob for compiledDigest=${row.compiledDigest}`);
32
49
  }
33
50
  if (compiledBlob === null) {
34
51
  // CRITICAL — version row points at a digest with no blob.
@@ -36,13 +53,14 @@ export async function readArtifact(input, deps) {
36
53
  return errorResult(500, 'server_error', `version row points at compiledDigest=${row.compiledDigest} but no compiled-blob row exists`);
37
54
  }
38
55
  }
39
- const body = rowToResponse(row, compiledBlob);
56
+ const scopeOwner = await resolveScopeOwner();
57
+ const body = rowToResponse(row, compiledBlob, scopeOwner);
40
58
  if (row.yanked === true) {
41
59
  return { ok: false, status: 410, body };
42
60
  }
43
61
  return { ok: true, status: 200, body };
44
62
  }
45
- function rowToResponse(row, compiledBlob) {
63
+ function rowToResponse(row, compiledBlob, scopeOwner) {
46
64
  return {
47
65
  manifest: row.manifest,
48
66
  bundleUrl: row.bundleUrl,
@@ -53,8 +71,30 @@ function rowToResponse(row, compiledBlob) {
53
71
  authorPublicKey: row.authorPublicKey,
54
72
  publishedAt: row.publishedAt,
55
73
  publishedBy: row.publishedBy,
74
+ scopeVerification: scopeOwner?.verification,
75
+ verifiedDomain: scopeOwner?.verification === 'verified' ? scopeOwner.verifiedDomain : undefined,
56
76
  };
57
77
  }
78
+ /**
79
+ * Structured server-side failure log. Raw storage error text stays
80
+ * OUT of wire bodies (it can carry backend identifiers a caller has
81
+ * no business seeing); this log line is the operator's copy.
82
+ */
83
+ function logStorageFailure(operation, artifactId, err) {
84
+ // eslint-disable-next-line no-console -- server-side operator signal; the wire stays generic
85
+ console.error(`registry read: failed to ${operation}`, {
86
+ artifactId,
87
+ error: err instanceof Error ? err.message : String(err),
88
+ });
89
+ }
90
+ /**
91
+ * The one not-found projection — used for BOTH a true miss and an
92
+ * unauthorized private read, so the two responses cannot drift apart
93
+ * (drift would reintroduce the existence signal).
94
+ */
95
+ function notFoundResult(input) {
96
+ return errorResult(404, 'not_found', `package ${input.artifactId}@${input.version} not found`);
97
+ }
58
98
  function errorResult(status, error, message) {
59
99
  return { ok: false, status, body: { error, message } };
60
100
  }
@@ -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"}