@ggui-ai/registry-server 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,7 @@ ggui gadget publish --auth=bearer --token=$GGUI_REGISTRY_TOKEN \
42
42
  | GET | `/bundles/:scope/:name/:version/bundle.js.sig` | none | Serve the signature envelope (immutable cache) |
43
43
  | GET | `/bundles/:scope/:name/:version/manifest.json` | none | Serve the manifest verbatim (immutable cache) |
44
44
 
45
- \* Reads of `visibility: 'private'` rows require bearer; unauthenticated callers see only public rows.
45
+ \* Reads of `visibility: 'private'` rows are ownership-gated: a private version is served only to the verified caller who published it or who owns the artifact's scope. Every other caller — unauthenticated included — receives the same `not_found` response as a genuinely missing artifact, and the version list filters unreadable versions (answering `not_found` when none are visible), so responses never reveal that a private artifact exists.
46
46
 
47
47
  ## Storage modes
48
48
 
@@ -60,7 +60,7 @@ This server is **MVP-scoped** for self-hosters who want the marketplace surface
60
60
  - Rate limiting (use a reverse proxy)
61
61
  - TLS termination (use a reverse proxy)
62
62
  - Backup automation (the filesystem mode is single-machine)
63
- - Per-org private artifact scoping (treat `visibility: 'private'` as a label, not enforcement)
63
+ - Per-org private artifact scoping (enforcement is per-subject — publisher or scope owner; org-membership admission is not implemented)
64
64
  - Sigstore transparency log integration
65
65
 
66
66
  If you need any of the above, build your own transport on top of [`@ggui-ai/registry-core`](https://github.com/ggui-ai/ggui/tree/main/packages/registry-core).
@@ -37,7 +37,7 @@
37
37
  * emits 401 with `{ error: 'unauthorized', message: ... }`.
38
38
  *
39
39
  * **Observable violation:**
40
- * - The integration test in `server.test.ts` exercises three negative
40
+ * - The integration test in `server.integration.test.ts` exercises three negative
41
41
  * paths (no header, wrong scheme, bad token) and asserts identical
42
42
  * 401 responses. Any divergence — a different status, a leaked
43
43
  * token hint in the body — is a contract violation.
@@ -1 +1 @@
1
- {"version":3,"file":"filesystem-bundle-storage.d.ts","sourceRoot":"","sources":["../src/filesystem-bundle-storage.ts"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAE5D,MAAM,WAAW,8BAA8B;IAC7C,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,8BAA8B,GACtC,aAAa,CA+Ef"}
1
+ {"version":3,"file":"filesystem-bundle-storage.d.ts","sourceRoot":"","sources":["../src/filesystem-bundle-storage.ts"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,aAAa,EAAc,MAAM,wBAAwB,CAAC;AAExE,MAAM,WAAW,8BAA8B;IAC7C,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,8BAA8B,GACtC,aAAa,CA0Ff"}
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * Filesystem-backed {@link BundleStorage} for the OSS registry server.
3
3
  *
4
- * Blob layout:
4
+ * Blob layout (H1 visibility split — placement is the access-control
5
+ * boundary):
5
6
  *
6
- * <root>/bundles/<scope>/<name>/<version>/bundle.js
7
- * <root>/bundles/<scope>/<name>/<version>/bundle.js.sig
8
- * <root>/bundles/<scope>/<name>/<version>/manifest.json
7
+ * <root>/bundles/public/<scope>/<name>/<version>/bundle.js
8
+ * <root>/bundles/public/<scope>/<name>/<version>/bundle.js.sig
9
+ * <root>/bundles/public/<scope>/<name>/<version>/manifest.json
10
+ * <root>/bundles/private/<scope>/<name>/<version>/{...}
9
11
  *
10
12
  * Scope is written verbatim (`@my-org/...`) because the leading `@`
11
13
  * is filename-safe on every supported OS. The URL composition mirrors
12
- * the same path so the public URL the install CLI follows resolves
13
- * directly against the file (the hono server's `/bundles/*` route
14
- * streams the file).
14
+ * the same path so the URL the install CLI follows resolves directly
15
+ * against the file: the hono server serves `/bundles/public/*`
16
+ * anonymously and `/bundles/private/*` through its authenticated
17
+ * bundle route.
15
18
  *
16
19
  * ## Path-traversal defense
17
20
  *
@@ -40,31 +43,39 @@ import { isGadgetSignature } from '@ggui-ai/gadget-signing';
40
43
  export function createFilesystemBundleStorage(options) {
41
44
  const bundlesRoot = join(options.root, 'bundles');
42
45
  const bundleHost = stripTrailingSlash(options.bundleHost);
43
- const dirFor = (scope, name, version) => {
46
+ const dirFor = (scope, name, version, visibility) => {
47
+ // `visibility` is a closed union ('public' | 'private') — no
48
+ // traversal surface; the free-form triple still gets the check.
44
49
  rejectTraversal(scope, 'scope');
45
50
  rejectTraversal(name, 'name');
46
51
  rejectTraversal(version, 'version');
47
- return join(bundlesRoot, scope, name, version);
52
+ return join(bundlesRoot, visibility, scope, name, version);
53
+ };
54
+ const urlFor = (scope, name, version, visibility, file) => {
55
+ rejectTraversal(scope, 'scope');
56
+ rejectTraversal(name, 'name');
57
+ rejectTraversal(version, 'version');
58
+ return `${bundleHost}/bundles/${visibility}/${scope}/${name}/${version}/${file}`;
48
59
  };
49
60
  return {
50
- async putBundle(scope, name, version, bytes) {
51
- const dir = dirFor(scope, name, version);
61
+ async putBundle(scope, name, version, visibility, bytes) {
62
+ const dir = dirFor(scope, name, version, visibility);
52
63
  await mkdir(dir, { recursive: true });
53
64
  await writeFile(join(dir, 'bundle.js'), bytes);
54
- return this.bundleUrl(scope, name, version);
65
+ return this.bundleUrl(scope, name, version, visibility);
55
66
  },
56
- async getBundle(scope, name, version) {
57
- const path = join(dirFor(scope, name, version), 'bundle.js');
67
+ async getBundle(scope, name, version, visibility) {
68
+ const path = join(dirFor(scope, name, version, visibility), 'bundle.js');
58
69
  return readFileOrNull(path);
59
70
  },
60
- async putSignature(scope, name, version, signature) {
61
- const dir = dirFor(scope, name, version);
71
+ async putSignature(scope, name, version, visibility, signature) {
72
+ const dir = dirFor(scope, name, version, visibility);
62
73
  await mkdir(dir, { recursive: true });
63
74
  await writeFile(join(dir, 'bundle.js.sig'), JSON.stringify(signature, null, 2), 'utf8');
64
- return this.signatureUrl(scope, name, version);
75
+ return this.signatureUrl(scope, name, version, visibility);
65
76
  },
66
- async getSignature(scope, name, version) {
67
- const path = join(dirFor(scope, name, version), 'bundle.js.sig');
77
+ async getSignature(scope, name, version, visibility) {
78
+ const path = join(dirFor(scope, name, version, visibility), 'bundle.js.sig');
68
79
  const text = await readTextOrNull(path);
69
80
  if (text === null)
70
81
  return null;
@@ -78,34 +89,25 @@ export function createFilesystemBundleStorage(options) {
78
89
  return null;
79
90
  return parsed;
80
91
  },
81
- async putManifest(scope, name, version, manifest) {
82
- const dir = dirFor(scope, name, version);
92
+ async putManifest(scope, name, version, visibility, manifest) {
93
+ const dir = dirFor(scope, name, version, visibility);
83
94
  await mkdir(dir, { recursive: true });
84
95
  await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
85
- return this.manifestUrl(scope, name, version);
96
+ return this.manifestUrl(scope, name, version, visibility);
86
97
  },
87
- async getManifest(scope, name, version) {
88
- const path = join(dirFor(scope, name, version), 'manifest.json');
98
+ async getManifest(scope, name, version, visibility) {
99
+ const path = join(dirFor(scope, name, version, visibility), 'manifest.json');
89
100
  const text = await readTextOrNull(path);
90
101
  return text === null ? null : JSON.parse(text);
91
102
  },
92
- bundleUrl(scope, name, version) {
93
- rejectTraversal(scope, 'scope');
94
- rejectTraversal(name, 'name');
95
- rejectTraversal(version, 'version');
96
- return `${bundleHost}/bundles/${scope}/${name}/${version}/bundle.js`;
103
+ bundleUrl(scope, name, version, visibility) {
104
+ return urlFor(scope, name, version, visibility, 'bundle.js');
97
105
  },
98
- signatureUrl(scope, name, version) {
99
- rejectTraversal(scope, 'scope');
100
- rejectTraversal(name, 'name');
101
- rejectTraversal(version, 'version');
102
- return `${bundleHost}/bundles/${scope}/${name}/${version}/bundle.js.sig`;
106
+ signatureUrl(scope, name, version, visibility) {
107
+ return urlFor(scope, name, version, visibility, 'bundle.js.sig');
103
108
  },
104
- manifestUrl(scope, name, version) {
105
- rejectTraversal(scope, 'scope');
106
- rejectTraversal(name, 'name');
107
- rejectTraversal(version, 'version');
108
- return `${bundleHost}/bundles/${scope}/${name}/${version}/manifest.json`;
109
+ manifestUrl(scope, name, version, visibility) {
110
+ return urlFor(scope, name, version, visibility, 'manifest.json');
109
111
  },
110
112
  };
111
113
  }
@@ -1,4 +1,4 @@
1
- import type { RegistryStorage } from '@ggui-ai/registry-core';
1
+ import { type RegistryStorage } from '@ggui-ai/registry-core';
2
2
  export interface FilesystemRegistryStorageOptions {
3
3
  /** Absolute path to the state root. Must already exist or be `mkdir -p`-able. */
4
4
  readonly root: string;
@@ -1 +1 @@
1
- {"version":3,"file":"filesystem-registry-storage.d.ts","sourceRoot":"","sources":["../src/filesystem-registry-storage.ts"],"names":[],"mappings":"AAiEA,OAAO,KAAK,EAMV,eAAe,EAChB,MAAM,wBAAwB,CAAC;AAKhC,MAAM,WAAW,gCAAgC;IAC/C,iFAAiF;IACjF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,+BAA+B,CAC7C,OAAO,EAAE,gCAAgC,GACxC,eAAe,CAkMjB"}
1
+ {"version":3,"file":"filesystem-registry-storage.d.ts","sourceRoot":"","sources":["../src/filesystem-registry-storage.ts"],"names":[],"mappings":"AAoEA,OAAO,EAQL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAKhC,MAAM,WAAW,gCAAgC;IAC/C,iFAAiF;IACjF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,+BAA+B,CAC7C,OAAO,EAAE,gCAAgC,GACxC,eAAe,CA2SjB"}
@@ -5,6 +5,7 @@
5
5
  * <root>/state/plugins/<encoded artifactId>.json → ArtifactsMetadataRow
6
6
  * <root>/state/versions/<encoded artifactId>__<v>.json → ArtifactVersionRow
7
7
  * <root>/state/author-keys/<subject>__<keyId>.json → AuthorKeyRow
8
+ * <root>/state/scope-owners/<encoded scope>.json → ScopeOwnerRow
8
9
  *
9
10
  * The `__` separator is chosen because npm pkg names + semvers never
10
11
  * contain it (semver disallows underscores entirely; npm names disallow
@@ -19,6 +20,8 @@
19
20
  * Mirrors DDB's `ConditionExpression: attribute_not_exists(...)`. The
20
21
  * happy-path write is atomic at the inode level — partial writes on
21
22
  * crash leave the file absent rather than half-populated.
23
+ * {@link claimScope} uses the same `wx` primitive for its first-writer-
24
+ * wins scope claim (`EEXIST` → `{ conflict: true }`).
22
25
  *
23
26
  * ## Path-traversal defense
24
27
  *
@@ -61,8 +64,9 @@
61
64
  * {@link registryStorageContract} suite against a fresh tmpdir per
62
65
  * test. Plus path-traversal rejection tests local to this file.
63
66
  */
64
- import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
67
+ import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
65
68
  import { dirname, join } from 'node:path';
69
+ import { AuthorKeyAlreadyExistsError, matchesMcpToolFilters, } from '@ggui-ai/registry-core';
66
70
  const DEFAULT_LIMIT = 50;
67
71
  const MAX_LIMIT = 200;
68
72
  export function createFilesystemRegistryStorage(options) {
@@ -74,6 +78,13 @@ export function createFilesystemRegistryStorage(options) {
74
78
  // hex digest + `.json`. The digest itself is filename-safe (hex
75
79
  // chars only), so no URL-encoding is needed.
76
80
  const compiledBlobsDir = join(stateRoot, 'compiled-blobs');
81
+ // Scope-ownership rows — one file per claimed scope. The scope is
82
+ // URL-encoded like every other row key (`@` → `%40`).
83
+ const scopeOwnersDir = join(stateRoot, 'scope-owners');
84
+ const scopeOwnerPath = (scope) => {
85
+ rejectTraversal(scope, 'scope');
86
+ return join(scopeOwnersDir, `${encodeRowKey(scope)}.json`);
87
+ };
77
88
  const compiledBlobPath = (compiledDigest) => {
78
89
  rejectTraversal(compiledDigest, 'compiledDigest');
79
90
  return join(compiledBlobsDir, `${compiledDigest}.json`);
@@ -84,7 +95,13 @@ export function createFilesystemRegistryStorage(options) {
84
95
  return join(versionsDir, `${encodeRowKey(artifactId)}__${encodeRowKey(version)}.json`);
85
96
  };
86
97
  const authorKeyPath = (subject, keyId) => {
87
- rejectTraversal(subject, 'subject');
98
+ // NO rejectTraversal on `subject`: subjects are operator-defined
99
+ // free text (a '/' is legal), and `encodeRowKey` neutralizes every
100
+ // traversal-capable character ('/' → %2F) before the value ever
101
+ // becomes a filename component — the appended `__<keyId>.json`
102
+ // suffix additionally guarantees the segment can never be a bare
103
+ // '..'. keyIds keep the guard as defense-in-depth (base64url by
104
+ // derivation — '.', '/', '\\' are unrepresentable).
88
105
  rejectTraversal(keyId, 'keyId');
89
106
  return join(authorKeysDir, `${encodeRowKey(subject)}__${encodeRowKey(keyId)}.json`);
90
107
  };
@@ -223,14 +240,94 @@ export function createFilesystemRegistryStorage(options) {
223
240
  throw err;
224
241
  }
225
242
  },
243
+ async getScopeOwner(scope) {
244
+ return readJsonOrNull(scopeOwnerPath(scope));
245
+ },
246
+ async claimScope(row) {
247
+ // Atomic first-writer-wins via `wx` (open(2) O_EXCL) — mirrors
248
+ // the conditional-create semantics of a hosted database adapter.
249
+ // EEXIST maps to `{ conflict: true }` with the winner's file
250
+ // untouched.
251
+ await ensureDir(scopeOwnersDir);
252
+ try {
253
+ await writeFile(scopeOwnerPath(row.scope), JSON.stringify(row, null, 2), {
254
+ flag: 'wx',
255
+ encoding: 'utf8',
256
+ });
257
+ return { ok: true };
258
+ }
259
+ catch (err) {
260
+ if (isErrnoException(err) && err.code === 'EEXIST') {
261
+ return { conflict: true };
262
+ }
263
+ throw err;
264
+ }
265
+ },
266
+ async updateScopeOwner(row, expect) {
267
+ // Compare-and-set against the caller's read snapshot. Atomicity
268
+ // note: the read-compare-write sequence relies on the OSS
269
+ // server's single-process, single-writer posture (the same
270
+ // documented caveat as commitVersionAndBlob's blob leg) — the
271
+ // `wx` seed path below is additionally inode-atomic.
272
+ await ensureDir(scopeOwnersDir);
273
+ const path = scopeOwnerPath(row.scope);
274
+ if ('absent' in expect) {
275
+ // Seed path — O_EXCL create, EEXIST means a claim (or another
276
+ // seed) landed first.
277
+ try {
278
+ await writeFile(path, JSON.stringify(row, null, 2), {
279
+ flag: 'wx',
280
+ encoding: 'utf8',
281
+ });
282
+ return { ok: true };
283
+ }
284
+ catch (err) {
285
+ if (isErrnoException(err) && err.code === 'EEXIST') {
286
+ return { conflict: true };
287
+ }
288
+ throw err;
289
+ }
290
+ }
291
+ const current = await readJsonOrNull(path);
292
+ if (current === null ||
293
+ current.ownerSubject !== expect.ownerSubject ||
294
+ current.verification !== expect.verification) {
295
+ return { conflict: true };
296
+ }
297
+ await writeJson(path, row);
298
+ return { ok: true };
299
+ },
226
300
  async getAuthorKey(subject, keyId) {
227
301
  return readJsonOrNull(authorKeyPath(subject, keyId));
228
302
  },
229
- async putAuthorKey(row) {
303
+ async putAuthorKey(row, options) {
230
304
  await ensureDir(authorKeysDir);
231
- await writeJson(authorKeyPath(row.subject, row.keyId), row);
305
+ const path = authorKeyPath(row.subject, row.keyId);
306
+ if (options?.ifNotExists === true) {
307
+ // O_EXCL conditional create — same `wx` primitive as
308
+ // putArtifactVersionIfAbsent/claimScope. EEXIST maps to the
309
+ // typed conflict the register op's TOCTOU close dispatches on.
310
+ try {
311
+ await writeFile(path, JSON.stringify(row, null, 2), {
312
+ flag: 'wx',
313
+ encoding: 'utf8',
314
+ });
315
+ return;
316
+ }
317
+ catch (err) {
318
+ if (isErrnoException(err) && err.code === 'EEXIST') {
319
+ throw new AuthorKeyAlreadyExistsError(row.subject, row.keyId);
320
+ }
321
+ throw err;
322
+ }
323
+ }
324
+ await writeJson(path, row);
232
325
  },
233
326
  async listAuthorKeys(subject) {
327
+ // Filename-prefix candidate scan, then a row-field filter — the
328
+ // row's own `subject` is the unambiguous ownership signal
329
+ // (encodeRowKey leaves '_' unescaped, so a prefix alone could
330
+ // cross-match subjects containing '__').
234
331
  const prefix = `${encodeRowKey(subject)}__`;
235
332
  let entries;
236
333
  try {
@@ -246,11 +343,25 @@ export function createFilesystemRegistryStorage(options) {
246
343
  if (!entry.startsWith(prefix) || !entry.endsWith('.json'))
247
344
  continue;
248
345
  const row = await readJsonOrNull(join(authorKeysDir, entry));
249
- if (row !== null)
346
+ if (row !== null && row.subject === subject)
250
347
  matches.push(row);
251
348
  }
252
349
  return matches;
253
350
  },
351
+ async deleteAuthorKey(subject, keyId) {
352
+ // ENOENT is the "row absent" signal (mirrors reads), mapped to
353
+ // the contract's `false`; anything else is a transport failure
354
+ // and throws.
355
+ try {
356
+ await unlink(authorKeyPath(subject, keyId));
357
+ return true;
358
+ }
359
+ catch (err) {
360
+ if (isErrnoException(err) && err.code === 'ENOENT')
361
+ return false;
362
+ throw err;
363
+ }
364
+ },
254
365
  };
255
366
  }
256
367
  /**
@@ -337,13 +448,17 @@ function parseCursor(cursor) {
337
448
  * Mirrors {@link inMemoryRegistryStorage}'s filter — same
338
449
  * AND-composition, same case-insensitive substring match on q, same
339
450
  * publisher-sub OR author-name match. Kept inline so the filesystem
340
- * impl doesn't depend on a private helper from registry-core.
451
+ * impl doesn't depend on a private helper from registry-core; the
452
+ * tool/server dimensions delegate to the PUBLIC shared predicate
453
+ * `matchesMcpToolFilters` so the reference semantics cannot drift.
341
454
  */
342
455
  function rowMatchesFilter(row, q) {
343
456
  if (q.kind !== undefined && row.kind !== q.kind)
344
457
  return false;
345
458
  if (q.hook !== undefined && row.hook !== q.hook)
346
459
  return false;
460
+ if (!matchesMcpToolFilters(row.mcpTools, q))
461
+ return false;
347
462
  if (q.tag !== undefined) {
348
463
  if (row.tags === undefined || !row.tags.includes(q.tag))
349
464
  return false;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BlueprintProbeRunner, BundleStorage, RegistryStorage } from '@ggui-ai/registry-core';
1
+ import type { BlueprintProbeRunner, BundleStorage, PublishArtifactDeps, RegistryStorage } from '@ggui-ai/registry-core';
2
2
  import type { BearerAuthn } from './authn/bearer.js';
3
3
  export { createBearerAuthn, type BearerAuthn, type CreateBearerAuthnOptions } from './authn/bearer.js';
4
4
  export { createFilesystemBundleStorage, type FilesystemBundleStorageOptions, } from './filesystem-bundle-storage.js';
@@ -40,6 +40,19 @@ export interface CreateRegistryServerOptions {
40
40
  * e2e fixtures, not unauthenticated public publish endpoints.
41
41
  */
42
42
  readonly blueprintProbe?: BlueprintProbeRunner;
43
+ /**
44
+ * Optional TUF trust-root tuning for sigstore-cosign publish
45
+ * verification. Forwarded to `createRegistryApp` (see
46
+ * {@link RegistryAppOptions.sigstoreTuf}).
47
+ */
48
+ readonly sigstoreTuf?: PublishArtifactDeps['sigstoreTuf'];
49
+ /**
50
+ * Optional verified-email lookup for the publish gate's identity
51
+ * binding. Forwarded to `createRegistryApp` (see
52
+ * {@link RegistryAppOptions.verifiedEmailResolver} for the default
53
+ * allowlist-only posture when unset).
54
+ */
55
+ readonly verifiedEmailResolver?: PublishArtifactDeps['verifiedEmailResolver'];
43
56
  }
44
57
  export interface RegistryServerHandle {
45
58
  /** The port the server is actually bound to. Resolves after {@link start}. */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EACV,oBAAoB,EACpB,aAAa,EACb,eAAe,EAChB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGrD,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,KAAK,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AACvG,OAAO,EACL,6BAA6B,EAC7B,KAAK,8BAA8B,GACpC,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,+BAA+B,EAC/B,KAAK,gCAAgC,GACtC,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAAE,iBAAiB,EAAE,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEzE,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,4BAA4B;IAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,oBAAoB,CAAC;CAChD;AAED,MAAM,WAAW,oBAAoB;IACnC,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,2BAA2B,GACnC,oBAAoB,CAiDtB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EACV,oBAAoB,EACpB,aAAa,EACb,mBAAmB,EACnB,eAAe,EAChB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGrD,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,KAAK,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AACvG,OAAO,EACL,6BAA6B,EAC7B,KAAK,8BAA8B,GACpC,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,+BAA+B,EAC/B,KAAK,gCAAgC,GACtC,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAAE,iBAAiB,EAAE,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEzE,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,4BAA4B;IAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,oBAAoB,CAAC;IAC/C;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,mBAAmB,CAAC,aAAa,CAAC,CAAC;IAC1D;;;;;OAKG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,mBAAmB,CAAC,uBAAuB,CAAC,CAAC;CAC/E;AAED,MAAM,WAAW,oBAAoB;IACnC,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,2BAA2B,GACnC,oBAAoB,CAuDtB"}
package/dist/index.js CHANGED
@@ -34,6 +34,12 @@ export function createRegistryServer(options) {
34
34
  ...(options.blueprintProbe !== undefined
35
35
  ? { blueprintProbe: options.blueprintProbe }
36
36
  : {}),
37
+ ...(options.sigstoreTuf !== undefined
38
+ ? { sigstoreTuf: options.sigstoreTuf }
39
+ : {}),
40
+ ...(options.verifiedEmailResolver !== undefined
41
+ ? { verifiedEmailResolver: options.verifiedEmailResolver }
42
+ : {}),
37
43
  });
38
44
  const host = options.host ?? '0.0.0.0';
39
45
  const requestedPort = options.port ?? 9001;
package/dist/server.d.ts CHANGED
@@ -6,25 +6,47 @@
6
6
  * GET /search → searchArtifacts
7
7
  * GET /pkg/:scope/:name/:version → readArtifact
8
8
  * POST /publish → publishArtifact (bearer-gated)
9
+ * POST /author-keys → registerAuthorKey (bearer-gated)
10
+ * GET /author-keys → listAuthorKeys (bearer-gated)
11
+ * DELETE /author-keys/:keyId → deleteAuthorKey (bearer-gated)
9
12
  * POST /conformance/check → checkConformance (pre-flight gate)
10
- * GET /bundles/:scope/:name/:version/bundle.js
11
- * GET /bundles/:scope/:name/:version/bundle.js.sig
12
- * GET /bundles/:scope/:name/:version/manifest.json
13
+ * GET /bundles/public/:scope/:name/:version/bundle.js (anonymous)
14
+ * GET /bundles/public/:scope/:name/:version/bundle.js.sig (anonymous)
15
+ * GET /bundles/public/:scope/:name/:version/manifest.json (anonymous)
16
+ * GET /bundles/private/:scope/:name/:version/bundle.js (bearer-gated)
17
+ * GET /bundles/private/:scope/:name/:version/bundle.js.sig (bearer-gated)
18
+ * GET /bundles/private/:scope/:name/:version/manifest.json (bearer-gated)
13
19
  *
14
20
  * Bundle / signature / manifest serves go through {@link BundleStorage}
15
21
  * directly so the operator can plug a different storage backend
16
22
  * (memory for tests; filesystem for self-hosting) without rewriting
17
- * the route table. The server always emits
18
- * `Cache-Control: public, max-age=31536000, immutable` on these
19
- * routes — bundles are SRI-pinned + immutable post-publish.
23
+ * the route table.
24
+ *
25
+ * H1 visibility split: blob placement mirrors the manifest's
26
+ * `visibility` (`bundles/public/…` vs `bundles/private/…`), and the
27
+ * ROUTE decides the auth posture — the public prefix serves
28
+ * anonymously; the private prefix requires a bearer AND authorizes the
29
+ * caller against the artifact's publisher or the scope owner (403
30
+ * otherwise; unknown triples answer 404). A blob is only findable
31
+ * through the prefix it was published under, so a private artifact can
32
+ * never be fetched via the anonymous route.
33
+ *
34
+ * Cache headers: public-prefix responses emit
35
+ * `Cache-Control: public, max-age=31536000, immutable` (bundles are
36
+ * SRI-pinned + immutable post-publish); private-prefix responses stay
37
+ * immutable but non-shared-cacheable
38
+ * (`Cache-Control: private, max-age=31536000, immutable`).
20
39
  *
21
40
  * CORS: permissive on public-read routes (`/search`, `/pkg/*`,
22
- * `/bundles/*`, `/conformance/check`); strict on `/publish` (same-origin
23
- * or no Origin header — the install CLI runs server-to-server, not
24
- * browser-side).
41
+ * `/bundles/public/*`, `/conformance/check`); strict on `/publish`
42
+ * (same-origin or no Origin header — the install CLI runs
43
+ * server-to-server, not browser-side) AND on `/bundles/private/*`
44
+ * (per-caller authorized content — no ACAO, no permissive preflight;
45
+ * browser consumers of private bundles go through presigned URLs
46
+ * rather than cross-origin credentialed fetches).
25
47
  */
26
48
  import { Hono } from 'hono';
27
- import { type BlueprintProbeRunner, type BundleStorage, type RegistryStorage } from '@ggui-ai/registry-core';
49
+ import { type BlueprintProbeRunner, type PublishArtifactDeps, type BundleStorage, type RegistryStorage } from '@ggui-ai/registry-core';
28
50
  import type { BearerAuthn } from './authn/bearer.js';
29
51
  export interface RegistryAppOptions {
30
52
  readonly storage: RegistryStorage;
@@ -41,6 +63,25 @@ export interface RegistryAppOptions {
41
63
  * only the static gates from `checkConformance`.
42
64
  */
43
65
  readonly blueprintProbe?: BlueprintProbeRunner;
66
+ /**
67
+ * Optional TUF trust-root tuning for sigstore-cosign publish
68
+ * verification, forwarded verbatim to the publish op. Point
69
+ * `tufMirrorURL` + `tufRootPath` at an alternative TUF repository
70
+ * (private sigstore deployments; hermetic tests); `tufCachePath` /
71
+ * `tufForceCache` control cache placement + warm reuse. Unset =
72
+ * the sigstore public-good trust root.
73
+ */
74
+ readonly sigstoreTuf?: PublishArtifactDeps['sigstoreTuf'];
75
+ /**
76
+ * Optional verified-email lookup for the publish gate's F4 identity
77
+ * binding — see {@link PublishArtifactDeps.verifiedEmailResolver}
78
+ * for the contract. Unset (the default here): publisher identity is
79
+ * enforced only on scopes whose ownership row carries a
80
+ * `sanAllowlist`; scopes without one accept any identity a valid
81
+ * sigstore bundle proves. Wire a resolver backed by your identity
82
+ * layer to bind default publishes to account emails.
83
+ */
84
+ readonly verifiedEmailResolver?: PublishArtifactDeps['verifiedEmailResolver'];
44
85
  }
45
86
  /**
46
87
  * Build the hono app. Does NOT bind a port — callers (the CLI, the
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EAKL,KAAK,oBAAoB,EAGzB,KAAK,aAAa,EAIlB,KAAK,eAAe,EACrB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAIrD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,oBAAoB,CAAC;CAChD;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAwQnE"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EAUL,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EAGxB,KAAK,aAAa,EAIlB,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AASrD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,oBAAoB,CAAC;IAC/C;;;;;;;OAOG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,mBAAmB,CAAC,aAAa,CAAC,CAAC;IAC1D;;;;;;;;OAQG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,mBAAmB,CAAC,uBAAuB,CAAC,CAAC;CAC/E;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAyXnE"}
package/dist/server.js CHANGED
@@ -6,26 +6,53 @@
6
6
  * GET /search → searchArtifacts
7
7
  * GET /pkg/:scope/:name/:version → readArtifact
8
8
  * POST /publish → publishArtifact (bearer-gated)
9
+ * POST /author-keys → registerAuthorKey (bearer-gated)
10
+ * GET /author-keys → listAuthorKeys (bearer-gated)
11
+ * DELETE /author-keys/:keyId → deleteAuthorKey (bearer-gated)
9
12
  * POST /conformance/check → checkConformance (pre-flight gate)
10
- * GET /bundles/:scope/:name/:version/bundle.js
11
- * GET /bundles/:scope/:name/:version/bundle.js.sig
12
- * GET /bundles/:scope/:name/:version/manifest.json
13
+ * GET /bundles/public/:scope/:name/:version/bundle.js (anonymous)
14
+ * GET /bundles/public/:scope/:name/:version/bundle.js.sig (anonymous)
15
+ * GET /bundles/public/:scope/:name/:version/manifest.json (anonymous)
16
+ * GET /bundles/private/:scope/:name/:version/bundle.js (bearer-gated)
17
+ * GET /bundles/private/:scope/:name/:version/bundle.js.sig (bearer-gated)
18
+ * GET /bundles/private/:scope/:name/:version/manifest.json (bearer-gated)
13
19
  *
14
20
  * Bundle / signature / manifest serves go through {@link BundleStorage}
15
21
  * directly so the operator can plug a different storage backend
16
22
  * (memory for tests; filesystem for self-hosting) without rewriting
17
- * the route table. The server always emits
18
- * `Cache-Control: public, max-age=31536000, immutable` on these
19
- * routes — bundles are SRI-pinned + immutable post-publish.
23
+ * the route table.
24
+ *
25
+ * H1 visibility split: blob placement mirrors the manifest's
26
+ * `visibility` (`bundles/public/…` vs `bundles/private/…`), and the
27
+ * ROUTE decides the auth posture — the public prefix serves
28
+ * anonymously; the private prefix requires a bearer AND authorizes the
29
+ * caller against the artifact's publisher or the scope owner (403
30
+ * otherwise; unknown triples answer 404). A blob is only findable
31
+ * through the prefix it was published under, so a private artifact can
32
+ * never be fetched via the anonymous route.
33
+ *
34
+ * Cache headers: public-prefix responses emit
35
+ * `Cache-Control: public, max-age=31536000, immutable` (bundles are
36
+ * SRI-pinned + immutable post-publish); private-prefix responses stay
37
+ * immutable but non-shared-cacheable
38
+ * (`Cache-Control: private, max-age=31536000, immutable`).
20
39
  *
21
40
  * CORS: permissive on public-read routes (`/search`, `/pkg/*`,
22
- * `/bundles/*`, `/conformance/check`); strict on `/publish` (same-origin
23
- * or no Origin header — the install CLI runs server-to-server, not
24
- * browser-side).
41
+ * `/bundles/public/*`, `/conformance/check`); strict on `/publish`
42
+ * (same-origin or no Origin header — the install CLI runs
43
+ * server-to-server, not browser-side) AND on `/bundles/private/*`
44
+ * (per-caller authorized content — no ACAO, no permissive preflight;
45
+ * browser consumers of private bundles go through presigned URLs
46
+ * rather than cross-origin credentialed fetches).
25
47
  */
26
48
  import { Hono } from 'hono';
27
- import { checkConformance, listArtifactVersions, publishArtifact, registerAuthorKey, readArtifact, searchArtifacts, } from '@ggui-ai/registry-core';
49
+ import { canReadPrivateArtifact, checkConformance, createScopeOwnerResolver, deleteAuthorKey, unauthorizedErrorBody, listArtifactVersions, listAuthorKeys, publishArtifact, registerAuthorKey, readArtifact, searchArtifacts, } from '@ggui-ai/registry-core';
28
50
  const IMMUTABLE_CACHE_HEADER = 'public, max-age=31536000, immutable';
51
+ /**
52
+ * Private-prefix responses are immutable too, but MUST NOT land in
53
+ * shared caches — the authorization decision is per-caller.
54
+ */
55
+ const PRIVATE_IMMUTABLE_CACHE_HEADER = 'private, max-age=31536000, immutable';
29
56
  /**
30
57
  * Build the hono app. Does NOT bind a port — callers (the CLI, the
31
58
  * `createRegistryServer` factory, programmatic embeds) decide how to
@@ -36,27 +63,41 @@ export function createRegistryApp(options) {
36
63
  const { storage, bundleStorage, authn, registryHostname } = options;
37
64
  const clock = options.clock ?? (() => new Date());
38
65
  const blueprintProbe = options.blueprintProbe;
66
+ const sigstoreTuf = options.sigstoreTuf;
67
+ const verifiedEmailResolver = options.verifiedEmailResolver;
39
68
  const app = new Hono();
40
- // CORS — permissive read; strict for /publish. Adds ACAO headers
41
- // AFTER the route runs so they overlay on the route's response.
69
+ // Shared bearer gate for every authed route — ONE verification call
70
+ // shape, ONE 401 body (`unauthorizedErrorBody`, exported beside the
71
+ // registry-core error enums so every transport of this wire contract
72
+ // agrees).
73
+ const verifyBearer = (c) => authn.verify(c.req.header('authorization'));
74
+ // CORS — permissive read; strict for /publish AND the private
75
+ // bundle prefix (review finding 5). Private bundles are per-caller
76
+ // authorized content: no cross-origin page has any business reading
77
+ // them, and the browser consumers that need private bundle bytes go
78
+ // through presigned URLs, not cross-origin credentialed fetches.
79
+ // Adds ACAO headers AFTER the route runs so they overlay on the
80
+ // route's response.
42
81
  app.use('*', async (c, next) => {
43
82
  const path = c.req.path;
44
- const isPublishRoute = path === '/publish';
45
- if (c.req.method === 'OPTIONS' && !isPublishRoute) {
46
- // Preflight short-circuit for read routes.
83
+ const isCorsExcluded = path === '/publish' || path.startsWith('/bundles/private/');
84
+ if (c.req.method === 'OPTIONS' && !isCorsExcluded) {
85
+ // Preflight short-circuit for read routes. DELETE is listed for
86
+ // the author-key management route — a browser console revokes
87
+ // keys cross-origin ([E]).
47
88
  return new Response(null, {
48
89
  status: 204,
49
90
  headers: {
50
91
  'Access-Control-Allow-Origin': '*',
51
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
92
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
52
93
  'Access-Control-Allow-Headers': 'content-type, authorization',
53
94
  },
54
95
  });
55
96
  }
56
97
  await next();
57
- if (!isPublishRoute) {
98
+ if (!isCorsExcluded) {
58
99
  c.res.headers.set('Access-Control-Allow-Origin', '*');
59
- c.res.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
100
+ c.res.headers.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
60
101
  c.res.headers.set('Access-Control-Allow-Headers', 'content-type, authorization');
61
102
  }
62
103
  });
@@ -74,6 +115,8 @@ export function createRegistryApp(options) {
74
115
  limit: q.limit,
75
116
  cursor: q.cursor,
76
117
  sort: q.sort,
118
+ tool: q.tool,
119
+ server: q.server,
77
120
  }, { storage });
78
121
  if (!result.ok) {
79
122
  return c.json(result.body, result.status);
@@ -135,13 +178,9 @@ export function createRegistryApp(options) {
135
178
  // Defensive: don't echo. Browsers will reject without the
136
179
  // ACAO header, which is the protection we want.
137
180
  }
138
- const authHeader = c.req.header('authorization');
139
- const verified = authn.verify(authHeader);
181
+ const verified = verifyBearer(c);
140
182
  if (verified === null) {
141
- return c.json({
142
- error: 'unauthorized',
143
- message: 'publish requires a valid bearer token in the Authorization header',
144
- }, 401);
183
+ return c.json(unauthorizedErrorBody(), 401);
145
184
  }
146
185
  let body;
147
186
  try {
@@ -162,6 +201,8 @@ export function createRegistryApp(options) {
162
201
  clock,
163
202
  registryHostname,
164
203
  ...(blueprintProbe !== undefined ? { blueprintProbe } : {}),
204
+ ...(sigstoreTuf !== undefined ? { sigstoreTuf } : {}),
205
+ ...(verifiedEmailResolver !== undefined ? { verifiedEmailResolver } : {}),
165
206
  });
166
207
  return c.json(result.body, result.status);
167
208
  });
@@ -170,13 +211,9 @@ export function createRegistryApp(options) {
170
211
  // bearer-authenticated subject's identity. Shares one wire contract
171
212
  // with the hosted registry's author-keys endpoint.
172
213
  app.post('/author-keys', async (c) => {
173
- const authHeader = c.req.header('authorization');
174
- const verified = authn.verify(authHeader);
214
+ const verified = verifyBearer(c);
175
215
  if (verified === null) {
176
- return c.json({
177
- error: 'unauthorized',
178
- message: 'author-keys requires a valid bearer token in the Authorization header',
179
- }, 401);
216
+ return c.json(unauthorizedErrorBody(), 401);
180
217
  }
181
218
  let rawBody;
182
219
  try {
@@ -201,7 +238,37 @@ export function createRegistryApp(options) {
201
238
  message: '`publicKeyBase64` must be a string',
202
239
  }, 400);
203
240
  }
204
- const result = await registerAuthorKey({ publicKeyBase64: body.publicKeyBase64 }, { storage, authn: verified });
241
+ const result = await registerAuthorKey({
242
+ publicKeyBase64: body.publicKeyBase64,
243
+ ...(body.label !== undefined ? { label: body.label } : {}),
244
+ }, { storage, authn: verified, clock });
245
+ return c.json(result.body, result.status);
246
+ });
247
+ // ── GET /author-keys ─────────────────────────────────────────────────
248
+ // Lists the bearer-authenticated subject's registered signing keys.
249
+ // Bearer REQUIRED: "my keys" has no anonymous projection, so a
250
+ // missing/invalid token is a 401 — never an empty anonymous list.
251
+ app.get('/author-keys', async (c) => {
252
+ const verified = verifyBearer(c);
253
+ if (verified === null) {
254
+ return c.json(unauthorizedErrorBody(), 401);
255
+ }
256
+ const result = await listAuthorKeys({ storage, authn: verified });
257
+ return c.json(result.body, result.status);
258
+ });
259
+ // ── DELETE /author-keys/:keyId ───────────────────────────────────────
260
+ // Hard-deletes the caller's `(subject, keyId)` row. Idempotent
261
+ // (absent → 200 `deleted: false`) and subject-scoped — see the op's
262
+ // docstring. Bearer REQUIRED, same as the list route. keyIds are
263
+ // base64url (`derivePublicKeyId`) — URL-safe by construction, the
264
+ // path param is the keyId verbatim.
265
+ app.delete('/author-keys/:keyId', async (c) => {
266
+ const verified = verifyBearer(c);
267
+ if (verified === null) {
268
+ return c.json(unauthorizedErrorBody(), 401);
269
+ }
270
+ const { keyId } = c.req.param();
271
+ const result = await deleteAuthorKey({ keyId }, { storage, authn: verified });
205
272
  return c.json(result.body, result.status);
206
273
  });
207
274
  // ── /conformance/check ───────────────────────────────────────────────
@@ -219,32 +286,106 @@ export function createRegistryApp(options) {
219
286
  // conformance handler's wire shape.
220
287
  return c.json(result, 200);
221
288
  });
222
- // ── /bundles/:scope/:name/:version/bundle.js ─────────────────────────
223
- app.get('/bundles/:scope/:name/:version/bundle.js', async (c) => {
289
+ // ── /bundles/public/… — anonymous, CDN-parity serving ────────────────
290
+ app.get('/bundles/public/:scope/:name/:version/bundle.js', async (c) => {
224
291
  const { scope, name, version } = c.req.param();
225
- return serveBundle(c, bundleStorage, scope, name, version);
292
+ return serveBundle(c, bundleStorage, scope, name, version, 'public');
226
293
  });
227
- // ── /bundles/:scope/:name/:version/bundle.js.sig ─────────────────────
228
- app.get('/bundles/:scope/:name/:version/bundle.js.sig', async (c) => {
294
+ app.get('/bundles/public/:scope/:name/:version/bundle.js.sig', async (c) => {
229
295
  const { scope, name, version } = c.req.param();
230
- return serveSignature(c, bundleStorage, scope, name, version);
296
+ return serveSignature(c, bundleStorage, scope, name, version, 'public');
231
297
  });
232
- // ── /bundles/:scope/:name/:version/manifest.json ─────────────────────
233
- app.get('/bundles/:scope/:name/:version/manifest.json', async (c) => {
298
+ app.get('/bundles/public/:scope/:name/:version/manifest.json', async (c) => {
234
299
  const { scope, name, version } = c.req.param();
235
- return serveManifest(c, bundleStorage, scope, name, version);
300
+ return serveManifest(c, bundleStorage, scope, name, version, 'public');
301
+ });
302
+ // ── /bundles/private/… — authenticated + ownership-authorized ────────
303
+ // Auth rule (H1): a valid bearer is required (401 — uniform, fires
304
+ // before any storage read, so it can't distinguish triples); then
305
+ // authorization goes through H2's shared `canReadPrivateArtifact`
306
+ // predicate (publisher OR scope owner; owner lookup lazy + memoized
307
+ // + fail-closed via `createScopeOwnerResolver`). Unknown triples,
308
+ // rows that are not private (their blobs live on the public prefix),
309
+ // AND authorization denials all answer the SAME opaque 404 — a
310
+ // distinguishable denial is an existence oracle for private
311
+ // artifacts. Storage faults answer the documented `server_error`
312
+ // envelope with NO raw error text (fail closed, log server-side).
313
+ const privateBundleGate = async (c, rawScope, name, version) => {
314
+ const verified = authn.verify(c.req.header('authorization'));
315
+ if (verified === null) {
316
+ return c.json({
317
+ error: 'unauthorized',
318
+ message: 'private bundles require a valid bearer token in the Authorization header',
319
+ }, 401);
320
+ }
321
+ const scope = rawScope.startsWith('@') ? rawScope : `@${rawScope}`;
322
+ const artifactId = `${scope}/${name}`;
323
+ let row;
324
+ try {
325
+ row = await storage.getArtifactVersion(artifactId, version);
326
+ }
327
+ catch (err) {
328
+ // Fail closed with the documented envelope — the raw fault text
329
+ // stays server-side (a wire-visible internals string is both a
330
+ // leak and a fingerprint).
331
+ // eslint-disable-next-line no-console -- server-side operator signal; the wire stays opaque
332
+ console.error('registry: private bundle gate storage read failed', {
333
+ artifactId,
334
+ version,
335
+ error: err instanceof Error ? err.message : String(err),
336
+ });
337
+ return c.json(errorBody('server_error', 'internal error'), 500);
338
+ }
339
+ if (row === null || row.visibility !== 'private') {
340
+ return c.json(errorBody('not_found', 'bundle not found'), 404);
341
+ }
342
+ // `createScopeOwnerResolver` already resolves owner-lookup faults
343
+ // to null (deny) with its own server-side log, so no try/catch is
344
+ // needed around the predicate.
345
+ const authorized = await canReadPrivateArtifact(verified, row, createScopeOwnerResolver(storage, artifactId));
346
+ if (!authorized) {
347
+ return c.json(errorBody('not_found', 'bundle not found'), 404);
348
+ }
349
+ return null;
350
+ };
351
+ app.get('/bundles/private/:scope/:name/:version/bundle.js', async (c) => {
352
+ const { scope, name, version } = c.req.param();
353
+ const denied = await privateBundleGate(c, scope, name, version);
354
+ if (denied !== null)
355
+ return denied;
356
+ return serveBundle(c, bundleStorage, scope, name, version, 'private');
357
+ });
358
+ app.get('/bundles/private/:scope/:name/:version/bundle.js.sig', async (c) => {
359
+ const { scope, name, version } = c.req.param();
360
+ const denied = await privateBundleGate(c, scope, name, version);
361
+ if (denied !== null)
362
+ return denied;
363
+ return serveSignature(c, bundleStorage, scope, name, version, 'private');
364
+ });
365
+ app.get('/bundles/private/:scope/:name/:version/manifest.json', async (c) => {
366
+ const { scope, name, version } = c.req.param();
367
+ const denied = await privateBundleGate(c, scope, name, version);
368
+ if (denied !== null)
369
+ return denied;
370
+ return serveManifest(c, bundleStorage, scope, name, version, 'private');
236
371
  });
237
372
  return app;
238
373
  }
374
+ /** Cache header for the given prefix — see the module docstring. */
375
+ function cacheHeaderFor(visibility) {
376
+ return visibility === 'public'
377
+ ? IMMUTABLE_CACHE_HEADER
378
+ : PRIVATE_IMMUTABLE_CACHE_HEADER;
379
+ }
239
380
  /**
240
381
  * Serve the bundle bytes. `application/javascript` MIME with the
241
- * immutable cache header. 404 on miss.
382
+ * visibility-appropriate immutable cache header. 404 on miss.
242
383
  */
243
- async function serveBundle(c, bundleStorage, rawScope, name, version) {
384
+ async function serveBundle(c, bundleStorage, rawScope, name, version, visibility) {
244
385
  const scope = rawScope.startsWith('@') ? rawScope : `@${rawScope}`;
245
386
  let bytes;
246
387
  try {
247
- bytes = await bundleStorage.getBundle(scope, name, version);
388
+ bytes = await bundleStorage.getBundle(scope, name, version, visibility);
248
389
  }
249
390
  catch (err) {
250
391
  return c.json(errorBody('server_error', errorMessage(err)), 500);
@@ -263,15 +404,15 @@ async function serveBundle(c, bundleStorage, rawScope, name, version) {
263
404
  status: 200,
264
405
  headers: {
265
406
  'Content-Type': 'application/javascript; charset=utf-8',
266
- 'Cache-Control': IMMUTABLE_CACHE_HEADER,
407
+ 'Cache-Control': cacheHeaderFor(visibility),
267
408
  },
268
409
  });
269
410
  }
270
- async function serveSignature(c, bundleStorage, rawScope, name, version) {
411
+ async function serveSignature(c, bundleStorage, rawScope, name, version, visibility) {
271
412
  const scope = rawScope.startsWith('@') ? rawScope : `@${rawScope}`;
272
413
  let sig;
273
414
  try {
274
- sig = await bundleStorage.getSignature(scope, name, version);
415
+ sig = await bundleStorage.getSignature(scope, name, version, visibility);
275
416
  }
276
417
  catch (err) {
277
418
  return c.json(errorBody('server_error', errorMessage(err)), 500);
@@ -279,14 +420,14 @@ async function serveSignature(c, bundleStorage, rawScope, name, version) {
279
420
  if (sig === null) {
280
421
  return c.json(errorBody('not_found', 'signature not found'), 404);
281
422
  }
282
- c.header('Cache-Control', IMMUTABLE_CACHE_HEADER);
423
+ c.header('Cache-Control', cacheHeaderFor(visibility));
283
424
  return c.json(sig, 200);
284
425
  }
285
- async function serveManifest(c, bundleStorage, rawScope, name, version) {
426
+ async function serveManifest(c, bundleStorage, rawScope, name, version, visibility) {
286
427
  const scope = rawScope.startsWith('@') ? rawScope : `@${rawScope}`;
287
428
  let manifest;
288
429
  try {
289
- manifest = await bundleStorage.getManifest(scope, name, version);
430
+ manifest = await bundleStorage.getManifest(scope, name, version, visibility);
290
431
  }
291
432
  catch (err) {
292
433
  return c.json(errorBody('server_error', errorMessage(err)), 500);
@@ -294,7 +435,7 @@ async function serveManifest(c, bundleStorage, rawScope, name, version) {
294
435
  if (manifest === null) {
295
436
  return c.json(errorBody('not_found', 'manifest not found'), 404);
296
437
  }
297
- c.header('Cache-Control', IMMUTABLE_CACHE_HEADER);
438
+ c.header('Cache-Control', cacheHeaderFor(visibility));
298
439
  return c.json(manifest, 200);
299
440
  }
300
441
  function errorBody(error, message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/registry-server",
3
- "version": "0.6.3",
3
+ "version": "0.8.0",
4
4
  "description": "Self-hostable HTTP server for the ggui marketplace registry. Wraps @ggui-ai/registry-core with hono, filesystem storage, and bearer-token auth. Run via npx for local dev, CI, and on-prem deployments.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -31,12 +31,16 @@
31
31
  "dependencies": {
32
32
  "@hono/node-server": "^2.0.12",
33
33
  "hono": "^4.12.8",
34
- "@ggui-ai/artifact-manifest": "0.6.3",
35
- "@ggui-ai/gadget-signing": "0.6.3",
36
- "@ggui-ai/registry-core": "0.6.3"
34
+ "@ggui-ai/artifact-manifest": "0.8.0",
35
+ "@ggui-ai/gadget-signing": "0.8.0",
36
+ "@ggui-ai/registry-core": "0.8.0"
37
37
  },
38
38
  "devDependencies": {
39
+ "@sigstore/mock": "~0.12.1",
40
+ "@sigstore/protobuf-specs": "^0.5.1",
41
+ "@tufjs/repo-mock": "4.0.1",
39
42
  "@types/node": "^24.0.0",
43
+ "nock": "^13.5.6",
40
44
  "tsx": "^4.19.0",
41
45
  "typescript": "^5.0.0",
42
46
  "vitest": "^3.2.6"