@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 +2 -2
- package/dist/authn/bearer.js +1 -1
- package/dist/filesystem-bundle-storage.d.ts.map +1 -1
- package/dist/filesystem-bundle-storage.js +41 -39
- package/dist/filesystem-registry-storage.d.ts +1 -1
- package/dist/filesystem-registry-storage.d.ts.map +1 -1
- package/dist/filesystem-registry-storage.js +121 -6
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/server.d.ts +51 -10
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +191 -50
- package/package.json +8 -4
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
|
|
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 (
|
|
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).
|
package/dist/authn/bearer.js
CHANGED
|
@@ -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":"
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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":"
|
|
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
|
|
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
|
-
|
|
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}. */
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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`
|
|
23
|
-
* or no Origin header — the install CLI runs
|
|
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
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA
|
|
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.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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`
|
|
23
|
-
* or no Origin header — the install CLI runs
|
|
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
|
-
//
|
|
41
|
-
//
|
|
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
|
|
45
|
-
if (c.req.method === 'OPTIONS' && !
|
|
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 (!
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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':
|
|
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',
|
|
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',
|
|
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.
|
|
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.
|
|
35
|
-
"@ggui-ai/gadget-signing": "0.
|
|
36
|
-
"@ggui-ai/registry-core": "0.
|
|
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"
|