@ggui-ai/registry-core 0.7.0 → 0.9.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 (45) 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 +10 -0
  5. package/dist/index.d.ts +4 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -1
  8. package/dist/interfaces/bundle-storage.d.ts +55 -31
  9. package/dist/interfaces/bundle-storage.d.ts.map +1 -1
  10. package/dist/interfaces/registry-storage.d.ts +24 -2
  11. package/dist/interfaces/registry-storage.d.ts.map +1 -1
  12. package/dist/mcp-tool-filters.d.ts +23 -0
  13. package/dist/mcp-tool-filters.d.ts.map +1 -0
  14. package/dist/mcp-tool-filters.js +14 -0
  15. package/dist/ops/compile.d.ts.map +1 -1
  16. package/dist/ops/compile.js +19 -1
  17. package/dist/ops/conformance.d.ts +8 -5
  18. package/dist/ops/conformance.d.ts.map +1 -1
  19. package/dist/ops/conformance.js +35 -7
  20. package/dist/ops/list-versions.d.ts +19 -10
  21. package/dist/ops/list-versions.d.ts.map +1 -1
  22. package/dist/ops/list-versions.js +43 -8
  23. package/dist/ops/private-read-authz.d.ts +88 -0
  24. package/dist/ops/private-read-authz.d.ts.map +1 -0
  25. package/dist/ops/private-read-authz.js +58 -0
  26. package/dist/ops/publish.d.ts +30 -0
  27. package/dist/ops/publish.d.ts.map +1 -1
  28. package/dist/ops/publish.js +163 -29
  29. package/dist/ops/read.d.ts +28 -6
  30. package/dist/ops/read.d.ts.map +1 -1
  31. package/dist/ops/read.js +47 -7
  32. package/dist/ops/search.d.ts +18 -3
  33. package/dist/ops/search.d.ts.map +1 -1
  34. package/dist/ops/search.js +54 -2
  35. package/dist/testing/bundle-storage-contract.d.ts.map +1 -1
  36. package/dist/testing/bundle-storage-contract.js +61 -27
  37. package/dist/testing/registry-storage-contract.d.ts.map +1 -1
  38. package/dist/testing/registry-storage-contract.js +148 -1
  39. package/dist/types.d.ts +115 -16
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js +30 -14
  42. package/dist/utils/lazy-import.d.ts +17 -0
  43. package/dist/utils/lazy-import.d.ts.map +1 -0
  44. package/dist/utils/lazy-import.js +27 -0
  45. package/package.json +11 -5
@@ -7,12 +7,20 @@
7
7
  * 1. Point-read the metadata row via {@link RegistryStorage.getArtifactMetadata}.
8
8
  * Missing metadata → 404 (`not_found`).
9
9
  * 2. Fetch all version rows via {@link RegistryStorage.listArtifactVersions}.
10
- * 3. Filter out private rows for unauthenticated callers — same gate
11
- * as the read op's `visibility === 'private' && authn === undefined`
12
- * branch. We do NOT 403 on a fully-private artifact for an unauthed
13
- * caller; we 200 with `versions: []` so the wire response doesn't
14
- * leak the existence of a private artifact (cf. GitHub's 404-on-
15
- * private-repo behaviour).
10
+ * 3. Filter out private rows the caller cannot read — the same
11
+ * ownership rule as the read op ({@link canReadPrivateArtifact}:
12
+ * publisher or scope owner). Unreadable rows are FILTERED, never
13
+ * errored: an unauthorized caller (anonymous or authenticated)
14
+ * gets the readable subset. When NOTHING is readable, the
15
+ * response is the SAME 404 `not_found` as a true miss — a 200
16
+ * `versions: []` would differ from the miss shape and that
17
+ * difference is an existence oracle (cf. GitHub's
18
+ * 404-on-private-repo behaviour). The scope-owner lookup is lazy,
19
+ * memoized, and fail-closed ({@link createScopeOwnerResolver}):
20
+ * at most one {@link RegistryStorage.getScopeOwner} call per
21
+ * request; none when every row is public, the caller published
22
+ * every private row, or the caller is anonymous; a lookup fault
23
+ * denies instead of erroring.
16
24
  * 4. Sort by semver DESC so the latest version is first. Yanked rows
17
25
  * are NOT filtered — they stay in the list with `yanked: true`
18
26
  * so the UI can show "this version was yanked".
@@ -38,10 +46,11 @@ export interface ListArtifactVersionsInput {
38
46
  export interface ListArtifactVersionsDeps {
39
47
  readonly storage: RegistryStorage;
40
48
  /**
41
- * Optional — when undefined, the op filters `private` versions OUT
42
- * of the response. Authenticated callers see every version they own
43
- * (the storage layer's row-level visibility filter is the source of
44
- * truth; this op just honours it).
49
+ * Optional — the verified caller context, produced by the
50
+ * transport's own credential verification. Private versions appear
51
+ * in the response only for the caller who published them or the
52
+ * owner of the artifact's scope ({@link canReadPrivateArtifact});
53
+ * every other caller — anonymous included — gets the public subset.
45
54
  */
46
55
  readonly authn?: AuthnContext;
47
56
  }
@@ -1 +1 @@
1
- {"version":3,"file":"list-versions.d.ts","sourceRoot":"","sources":["../../src/ops/list-versions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAEV,qBAAqB,EAErB,oBAAoB,EAErB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAGzE,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;CAC/B;AAED,MAAM,MAAM,0BAA0B,GAClC;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;CACrC,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;CACtC,CAAC;AAEN,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,yBAAyB,EAChC,IAAI,EAAE,wBAAwB,GAC7B,OAAO,CAAC,0BAA0B,CAAC,CA6DrC"}
1
+ {"version":3,"file":"list-versions.d.ts","sourceRoot":"","sources":["../../src/ops/list-versions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,KAAK,EAEV,qBAAqB,EAErB,oBAAoB,EAErB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAOzE,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;CAC/B;AAED,MAAM,MAAM,0BAA0B,GAClC;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;CACrC,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;CACtC,CAAC;AAEN,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,yBAAyB,EAChC,IAAI,EAAE,wBAAwB,GAC7B,OAAO,CAAC,0BAA0B,CAAC,CAsErC"}
@@ -1,4 +1,5 @@
1
1
  import { compareSemver } from '../utils/semver.js';
2
+ import { canReadPrivateArtifact, createScopeOwnerResolver, } from './private-read-authz.js';
2
3
  export async function listArtifactVersions(input, deps) {
3
4
  if (typeof input.artifactId !== 'string' || input.artifactId.length === 0) {
4
5
  return errorResult(400, 'invalid_request', 'missing artifactId');
@@ -12,10 +13,11 @@ export async function listArtifactVersions(input, deps) {
12
13
  metadataExists = metadata !== null;
13
14
  }
14
15
  catch (err) {
15
- return errorResult(500, 'server_error', `failed to read metadata: ${err instanceof Error ? err.message : String(err)}`);
16
+ logStorageFailure('read metadata', input.artifactId, err);
17
+ return errorResult(500, 'server_error', 'failed to list versions');
16
18
  }
17
19
  if (!metadataExists) {
18
- return errorResult(404, 'not_found', `no such artifact: ${input.artifactId}`);
20
+ return notFoundResult(input);
19
21
  }
20
22
  // Step 2 — fetch all version rows.
21
23
  let rows;
@@ -23,18 +25,31 @@ export async function listArtifactVersions(input, deps) {
23
25
  rows = await deps.storage.listArtifactVersions(input.artifactId);
24
26
  }
25
27
  catch (err) {
26
- return errorResult(500, 'server_error', `failed to list versions: ${err instanceof Error ? err.message : String(err)}`);
28
+ logStorageFailure('list version rows', input.artifactId, err);
29
+ return errorResult(500, 'server_error', 'failed to list versions');
27
30
  }
28
- // Step 3 — visibility filter. Unauthed callers see only public rows;
29
- // authed callers see everything (finer org-membership gating is a
30
- // future concern — for now, "authed" === "can see your own private
31
- // rows" is captured by the verified caller subject being non-null).
31
+ // Step 3 — ownership filter. A private row stays in the list only
32
+ // when the caller may read it under the shared rule (publisher or
33
+ // scope owner). All rows share one artifactId, hence one scope —
34
+ // the shared resolver memoizes the lazy owner lookup (one
35
+ // getScopeOwner call at most; none for public rows / anonymous
36
+ // callers / publisher-only matches) and fails CLOSED on a storage
37
+ // fault (deny + server-side log, never a distinctive error status).
38
+ const getScopeOwner = createScopeOwnerResolver(deps.storage, input.artifactId);
32
39
  const visibleRows = [];
33
40
  for (const row of rows) {
34
- if (row.visibility === 'private' && deps.authn === undefined)
41
+ if (row.visibility === 'private' &&
42
+ !(await canReadPrivateArtifact(deps.authn, row, getScopeOwner))) {
35
43
  continue;
44
+ }
36
45
  visibleRows.push(row);
37
46
  }
47
+ // No visible versions ⇒ answer exactly as a true miss. Emitting a
48
+ // 200 `versions: []` here would differ from the miss shape, and
49
+ // that difference is an existence oracle for private artifacts.
50
+ if (visibleRows.length === 0) {
51
+ return notFoundResult(input);
52
+ }
38
53
  // Step 4 — semver DESC sort. `compareSemver(a, b)` returns -1/0/1
39
54
  // matching ascending order; flip the sign for DESC.
40
55
  const sorted = [...visibleRows].sort((a, b) => -compareSemver(a.version, b.version));
@@ -55,6 +70,26 @@ function rowToEntry(row) {
55
70
  visibility: row.visibility,
56
71
  };
57
72
  }
73
+ /**
74
+ * The one not-found projection — used for a true miss AND an artifact
75
+ * with no versions visible to the caller, so the two responses cannot
76
+ * drift apart (drift would reintroduce the existence signal).
77
+ */
78
+ function notFoundResult(input) {
79
+ return errorResult(404, 'not_found', `no such artifact: ${input.artifactId}`);
80
+ }
81
+ /**
82
+ * Structured server-side failure log. Raw storage error text stays
83
+ * OUT of wire bodies (it can carry backend identifiers a caller has
84
+ * no business seeing); this log line is the operator's copy.
85
+ */
86
+ function logStorageFailure(operation, artifactId, err) {
87
+ // eslint-disable-next-line no-console -- server-side operator signal; the wire stays generic
88
+ console.error(`registry list-versions: failed to ${operation}`, {
89
+ artifactId,
90
+ error: err instanceof Error ? err.message : String(err),
91
+ });
92
+ }
58
93
  function errorResult(status, error, message) {
59
94
  return { ok: false, status, body: { error, message } };
60
95
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The single ownership rule for private-row reads.
3
+ *
4
+ * A `visibility: 'private'` row is readable when the verified caller
5
+ * is the row's publisher (`authn.subject === row.publishedBy`) OR the
6
+ * owner of the artifact's scope (`authn.subject === ScopeOwnerRow.
7
+ * ownerSubject`). Everyone else — including anonymous callers — MUST
8
+ * receive a response indistinguishable from "no such artifact" so a
9
+ * probe can never confirm a private artifact exists.
10
+ *
11
+ * Every read-path consumer (the read op, the list-versions op, any
12
+ * transport route that serves private artifact content — bundle and
13
+ * signature byte routes included) MUST authorize through this one
14
+ * predicate. A second hand-rolled comparison is how the rule forks;
15
+ * import this instead. Note the byte-route obligation explicitly:
16
+ * metadata gating alone is insufficient while bundle URLs serve
17
+ * private bytes anonymously — a deployment MUST place its private
18
+ * bundle storage behind a route that applies this same predicate
19
+ * (the hosted deployment's public/private storage-prefix split does
20
+ * exactly that).
21
+ *
22
+ * ## Protocol & Contract Bar
23
+ *
24
+ * **Parties:** read-path ops and transport routes are the callers;
25
+ * the deployment's {@link RegistryStorage.getScopeOwner} backs the
26
+ * lazy scope-owner lookup.
27
+ *
28
+ * **Obligations:** callers MUST pass the verified caller context (or
29
+ * `undefined` for anonymous) — never a caller-supplied subject. The
30
+ * `getScopeOwner` thunk MUST resolve the ownership row for the
31
+ * artifact's scope (see {@link artifactScope}); it is invoked at most
32
+ * once, and ONLY when the caller is authenticated and not the
33
+ * publisher — the hot public path and the publisher fast path incur
34
+ * zero extra storage reads. Callers SHOULD build the thunk via
35
+ * {@link createScopeOwnerResolver}, which adds memoization and the
36
+ * fail-closed error posture.
37
+ *
38
+ * **Failure mode:** {@link createScopeOwnerResolver} resolves a
39
+ * storage failure to `null` (unclaimed) — the owner arm DENIES, the
40
+ * caller answers with its ordinary miss shape, and the fault is
41
+ * logged server-side. Failing open would serve the private row;
42
+ * failing loud (a 5xx only private rows can trigger) would leak
43
+ * existence. A hand-built thunk that rejects propagates to the
44
+ * caller instead — prefer the resolver.
45
+ *
46
+ * **Observable violation:** a private row served to a subject that is
47
+ * neither its `publishedBy` nor its scope's `ownerSubject` — pinned
48
+ * by the op suites (`read.test.ts`, `list-versions.test.ts`) and the
49
+ * server parity suite.
50
+ */
51
+ import type { AuthnContext } from '../interfaces/authn.js';
52
+ import type { RegistryStorage } from '../interfaces/registry-storage.js';
53
+ import type { ArtifactVersionRow, ScopeOwnerRow } from '../types.js';
54
+ /**
55
+ * Decide whether `authn` may read a private row.
56
+ *
57
+ * @param authn verified caller context; `undefined` for anonymous.
58
+ * @param row the private row under decision (only `publishedBy` is
59
+ * consulted — pass the full row or a narrowed projection).
60
+ * @param getScopeOwner lazy scope-owner lookup for the artifact's
61
+ * scope. Called at most once, only when the publisher fast path
62
+ * misses. Build it with {@link createScopeOwnerResolver} for
63
+ * memoization + fail-closed semantics.
64
+ */
65
+ export declare function canReadPrivateArtifact(authn: AuthnContext | undefined, row: Pick<ArtifactVersionRow, 'publishedBy'>, getScopeOwner: () => Promise<ScopeOwnerRow | null>): Promise<boolean>;
66
+ /**
67
+ * Build the memoized, fail-closed `getScopeOwner` thunk for
68
+ * {@link canReadPrivateArtifact}.
69
+ *
70
+ * - **Lazy** — no storage read happens until the owner arm is
71
+ * actually needed (public rows, anonymous callers, and publisher
72
+ * matches never trigger one).
73
+ * - **Memoized** — a caller checking many rows of one artifact
74
+ * (the list op) pays for at most one lookup.
75
+ * - **Fail-closed** — a storage failure resolves to `null`
76
+ * (unclaimed ⇒ deny) and is logged server-side with structure;
77
+ * the raw error never reaches a wire body. See the failure-mode
78
+ * section above for why neither fail-open nor fail-loud is
79
+ * acceptable here.
80
+ */
81
+ export declare function createScopeOwnerResolver(storage: Pick<RegistryStorage, 'getScopeOwner'>, artifactId: string): () => Promise<ScopeOwnerRow | null>;
82
+ /**
83
+ * Extract the scope segment (leading `@` included) from a canonical
84
+ * `<@scope>/<name>` artifactId — the key for
85
+ * {@link RegistryStorage.getScopeOwner}.
86
+ */
87
+ export declare function artifactScope(artifactId: string): string;
88
+ //# sourceMappingURL=private-read-authz.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"private-read-authz.d.ts","sourceRoot":"","sources":["../../src/ops/private-read-authz.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AACzE,OAAO,KAAK,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAErE;;;;;;;;;;GAUG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,YAAY,GAAG,SAAS,EAC/B,GAAG,EAAE,IAAI,CAAC,kBAAkB,EAAE,aAAa,CAAC,EAC5C,aAAa,EAAE,MAAM,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,GACjD,OAAO,CAAC,OAAO,CAAC,CAKlB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,IAAI,CAAC,eAAe,EAAE,eAAe,CAAC,EAC/C,UAAU,EAAE,MAAM,GACjB,MAAM,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAiBrC;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAGxD"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Decide whether `authn` may read a private row.
3
+ *
4
+ * @param authn verified caller context; `undefined` for anonymous.
5
+ * @param row the private row under decision (only `publishedBy` is
6
+ * consulted — pass the full row or a narrowed projection).
7
+ * @param getScopeOwner lazy scope-owner lookup for the artifact's
8
+ * scope. Called at most once, only when the publisher fast path
9
+ * misses. Build it with {@link createScopeOwnerResolver} for
10
+ * memoization + fail-closed semantics.
11
+ */
12
+ export async function canReadPrivateArtifact(authn, row, getScopeOwner) {
13
+ if (authn === undefined)
14
+ return false;
15
+ if (authn.subject === row.publishedBy)
16
+ return true;
17
+ const owner = await getScopeOwner();
18
+ return owner !== null && owner.ownerSubject === authn.subject;
19
+ }
20
+ /**
21
+ * Build the memoized, fail-closed `getScopeOwner` thunk for
22
+ * {@link canReadPrivateArtifact}.
23
+ *
24
+ * - **Lazy** — no storage read happens until the owner arm is
25
+ * actually needed (public rows, anonymous callers, and publisher
26
+ * matches never trigger one).
27
+ * - **Memoized** — a caller checking many rows of one artifact
28
+ * (the list op) pays for at most one lookup.
29
+ * - **Fail-closed** — a storage failure resolves to `null`
30
+ * (unclaimed ⇒ deny) and is logged server-side with structure;
31
+ * the raw error never reaches a wire body. See the failure-mode
32
+ * section above for why neither fail-open nor fail-loud is
33
+ * acceptable here.
34
+ */
35
+ export function createScopeOwnerResolver(storage, artifactId) {
36
+ let lookup;
37
+ return () => {
38
+ lookup ??= storage.getScopeOwner(artifactScope(artifactId)).catch((err) => {
39
+ // eslint-disable-next-line no-console -- server-side operator signal; the wire stays opaque
40
+ console.error('registry: scope-owner lookup failed; treating scope as unclaimed (fail closed)', {
41
+ artifactId,
42
+ scope: artifactScope(artifactId),
43
+ error: err instanceof Error ? err.message : String(err),
44
+ });
45
+ return null;
46
+ });
47
+ return lookup;
48
+ };
49
+ }
50
+ /**
51
+ * Extract the scope segment (leading `@` included) from a canonical
52
+ * `<@scope>/<name>` artifactId — the key for
53
+ * {@link RegistryStorage.getScopeOwner}.
54
+ */
55
+ export function artifactScope(artifactId) {
56
+ const slash = artifactId.indexOf('/');
57
+ return slash === -1 ? artifactId : artifactId.slice(0, slash);
58
+ }
@@ -93,7 +93,37 @@ export interface PublishArtifactDeps {
93
93
  * consults any of these.
94
94
  */
95
95
  readonly sigstoreTuf?: Pick<VerifyBundleSigstoreInput, 'tufCachePath' | 'tufForceCache' | 'tufMirrorURL' | 'tufRootPath'>;
96
+ /**
97
+ * F4 identity binding — resolve the VERIFIED email address of the
98
+ * publishing account identified by `subject` (the same value as
99
+ * {@link AuthnContext.subject}). Return `undefined` when the account
100
+ * has no verified email; the resolver MUST NOT return an address the
101
+ * deployment's identity layer has not verified, because the publish
102
+ * gate authorizes signer identities against it.
103
+ *
104
+ * Consumed only on sigstore-signed publishes into a scope whose
105
+ * ownership row carries NO `sanAllowlist`: the bundle's certificate
106
+ * SAN must then equal the resolved email (compared
107
+ * case-insensitively). Scopes WITH an allowlist never consult the
108
+ * resolver — the allowlist is the stricter, operator-managed rule.
109
+ *
110
+ * OPTIONAL, and honestly so: a deployment that does not wire a
111
+ * resolver enforces publisher identity ONLY through per-scope
112
+ * allowlists ({@link ScopeOwnerRow.sanAllowlist}); scopes without
113
+ * one accept any identity a valid sigstore bundle proves. Wire a
114
+ * resolver backed by your identity provider to bind default
115
+ * publishes to account emails.
116
+ */
117
+ readonly verifiedEmailResolver?: VerifiedEmailResolver;
96
118
  }
119
+ /**
120
+ * Deployment-provided lookup from an authenticated caller subject to
121
+ * that account's VERIFIED email address. See
122
+ * {@link PublishArtifactDeps.verifiedEmailResolver} for the contract
123
+ * (parties, obligations, and the fail-closed posture the publish gate
124
+ * layers on top).
125
+ */
126
+ export type VerifiedEmailResolver = (subject: string) => Promise<string | undefined>;
97
127
  export type PublishArtifactResult = {
98
128
  readonly ok: true;
99
129
  readonly status: 201;
@@ -1 +1 @@
1
- {"version":3,"file":"publish.d.ts","sourceRoot":"","sources":["../../src/ops/publish.ts"],"names":[],"mappings":"AA6CA,OAAO,EAML,KAAK,eAAe,EACpB,KAAK,yBAAyB,EAC/B,MAAM,yBAAyB,CAAC;AAGjC,OAAO,KAAK,EAIV,gBAAgB,EAEhB,mBAAmB,EACpB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAMzE;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,QAAkB,CAAC;AAEhD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAc5C,CAAC;AAEF,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAC;CACrC;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5C;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,oBAAoB,CAAC;IAC/C;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,IAAI,CACzB,yBAAyB,EACzB,cAAc,GAAG,eAAe,GAAG,cAAc,GAAG,aAAa,CAClE,CAAC;CACH;AAED,MAAM,MAAM,qBAAqB,GAC7B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAA;CAAE,GAC/E;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;CACjC,CAAC;AAEN,wBAAsB,eAAe,CACnC,KAAK,EAAE,oBAAoB,EAC3B,IAAI,EAAE,mBAAmB,GACxB,OAAO,CAAC,qBAAqB,CAAC,CAqfhC"}
1
+ {"version":3,"file":"publish.d.ts","sourceRoot":"","sources":["../../src/ops/publish.ts"],"names":[],"mappings":"AAwDA,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,yBAAyB,EAC/B,MAAM,yBAAyB,CAAC;AAGjC,OAAO,KAAK,EAIV,gBAAgB,EAEhB,mBAAmB,EAEpB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAMzE;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,QAAkB,CAAC;AAEhD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAc5C,CAAC;AAEF,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAC;CACrC;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5C;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,oBAAoB,CAAC;IAC/C;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,IAAI,CACzB,yBAAyB,EACzB,cAAc,GAAG,eAAe,GAAG,cAAc,GAAG,aAAa,CAClE,CAAC;IACF;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,qBAAqB,CAAC;CACxD;AAED;;;;;;GAMG;AACH,MAAM,MAAM,qBAAqB,GAAG,CAClC,OAAO,EAAE,MAAM,KACZ,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjC,MAAM,MAAM,qBAAqB,GAC7B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAA;CAAE,GAC/E;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;CACjC,CAAC;AAEN,wBAAsB,eAAe,CACnC,KAAK,EAAE,oBAAoB,EAC3B,IAAI,EAAE,mBAAmB,GACxB,OAAO,CAAC,qBAAqB,CAAC,CAupBhC"}
@@ -18,11 +18,21 @@
18
18
  * unclaimed scope is claimed for the caller via the atomic
19
19
  * {@link RegistryStorage.claimScope} (first-writer-wins; a lost
20
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.
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.
26
36
  * 3. Decode + size-check the bundle (gadgets only).
27
37
  * 4. Recompute SHA-384 of the bundle bytes; compare to client claim.
28
38
  * 5. Re-run the conformance gate ({@link checkConformance}).
@@ -38,11 +48,11 @@
38
48
  * `latestVersion` when the new version is the highest semver.
39
49
  * 10. Return 201 with the wire-locked {@link PublishResponseBody}.
40
50
  */
41
- import { manifestToRegistryEntry, parseArtifactManifest, } from '@ggui-ai/artifact-manifest';
42
- import { canonicalJson, extractSigstoreLeafCertPem, isGadgetSignature, verifyBundleEd25519, verifyBundleSigstore, } from '@ggui-ai/gadget-signing';
51
+ import { manifestToRegistryEntry, parseArtifactManifest, resolveMcpToolBindings, } from '@ggui-ai/artifact-manifest';
52
+ import { canonicalJson, extractSigstoreLeafCertPem, extractSigstoreSANs, isGadgetSignature, verifyBundleEd25519, verifyBundleSigstore, } from '@ggui-ai/gadget-signing';
43
53
  import { bundleHostScheme, strictGadgetDescriptorSchema } from '@ggui-ai/protocol';
44
54
  import { ZodError } from 'zod';
45
- import { ARTIFACTS_METADATA_SK } from '../types.js';
55
+ import { ARTIFACTS_METADATA_SK, SAN_ALLOWLIST_INVALID } from '../types.js';
46
56
  import { safeBase64Decode, sha384Base64 } from '../utils/base64.js';
47
57
  import { compareSemver } from '../utils/semver.js';
48
58
  import { compileBlueprint } from './compile.js';
@@ -145,24 +155,127 @@ export async function publishArtifact(input, deps) {
145
155
  // somebody else's row is now durable and the re-read decides whose
146
156
  // scope this is.
147
157
  //
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.
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.
156
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
+ };
157
259
  const existingOwner = await deps.storage.getScopeOwner(manifest.scope);
158
260
  if (existingOwner !== null && existingOwner.ownerSubject !== deps.authn.subject) {
159
261
  return scopeForbiddenByOwner();
160
262
  }
161
- if (existingOwner === null) {
263
+ if (existingOwner !== null) {
264
+ const identityFailure = await checkPublisherIdentity(existingOwner);
265
+ if (identityFailure !== undefined)
266
+ return identityFailure;
267
+ }
268
+ else {
162
269
  const reservedScopes = deps.reservedScopes ?? RESERVED_SCOPES;
163
270
  if (reservedScopes.includes(manifest.scope)) {
164
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.`);
165
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;
166
279
  const claim = await deps.storage.claimScope({
167
280
  scope: manifest.scope,
168
281
  ownerSubject: deps.authn.subject,
@@ -180,6 +293,12 @@ export async function publishArtifact(input, deps) {
180
293
  if (winner.ownerSubject !== deps.authn.subject) {
181
294
  return scopeForbiddenByOwner();
182
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;
183
302
  }
184
303
  }
185
304
  // 3. Bundle decode + size (gadgets only)
@@ -234,7 +353,7 @@ export async function publishArtifact(input, deps) {
234
353
  const conformanceBundleText = bundleBytes === undefined
235
354
  ? undefined
236
355
  : Buffer.from(bundleBytes.buffer, bundleBytes.byteOffset, bundleBytes.byteLength).toString('utf8');
237
- const conformanceResult = checkConformance({
356
+ const conformanceResult = await checkConformance({
238
357
  manifest,
239
358
  bundle: conformanceBundleText,
240
359
  });
@@ -282,12 +401,15 @@ export async function publishArtifact(input, deps) {
282
401
  }
283
402
  else {
284
403
  // Public gadgets — sigstore (Fulcio + Rekor) trust chain.
285
- // Identity claim: trust ANY valid OIDC identity at publish-time —
286
- // the publisher is already authenticated by the transport layer
287
- // ahead of this op. Install-time consumers CAN tighten via
288
- // `--verify-identity <pattern>` (CLI install flag); that's a
289
- // separate trust decision controlled by the install operator, not
290
- // 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.
291
413
  const verifyResult = await verifyBundleSigstore({
292
414
  bundleBytes: bytesForSignature,
293
415
  signature: input.signature,
@@ -364,12 +486,17 @@ export async function publishArtifact(input, deps) {
364
486
  // `version_exists` immediately — the publisher's idempotent retry path.
365
487
  const nowIso = deps.clock().toISOString();
366
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.
367
494
  const bundleUrl = bundleBytes === undefined
368
495
  ? undefined
369
- : deps.bundleStorage.bundleUrl(manifest.scope, manifest.name, version);
496
+ : deps.bundleStorage.bundleUrl(manifest.scope, manifest.name, version, manifest.visibility);
370
497
  const signatureUrl = bundleBytes === undefined
371
498
  ? undefined
372
- : deps.bundleStorage.signatureUrl(manifest.scope, manifest.name, version);
499
+ : deps.bundleStorage.signatureUrl(manifest.scope, manifest.name, version, manifest.visibility);
373
500
  const versionRow = {
374
501
  artifactId,
375
502
  version,
@@ -408,10 +535,10 @@ export async function publishArtifact(input, deps) {
408
535
  let manifestUrl;
409
536
  try {
410
537
  if (bundleBytes !== undefined) {
411
- await deps.bundleStorage.putBundle(manifest.scope, manifest.name, version, bundleBytes);
412
- 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);
413
540
  }
414
- 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);
415
542
  }
416
543
  catch (err) {
417
544
  return error(500, 'internal', `failed to upload artifact: ${errorMessage(err)}`);
@@ -429,6 +556,11 @@ export async function publishArtifact(input, deps) {
429
556
  // export name. The package may export several hooks/components;
430
557
  // the manifest's `exports[]` is the source of truth.
431
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);
432
564
  const metadataRow = {
433
565
  artifactId,
434
566
  sk: ARTIFACTS_METADATA_SK,
@@ -442,6 +574,8 @@ export async function publishArtifact(input, deps) {
442
574
  : 'hook' in primaryExport
443
575
  ? primaryExport.hook
444
576
  : primaryExport.component,
577
+ mcpTools: bindingResolution?.bindings,
578
+ mcpToolsSource: bindingResolution?.source,
445
579
  authorName: manifest.author?.name,
446
580
  publishedAt: nowIso,
447
581
  publishedBy: deps.authn.subject,