@ggui-ai/registry-server 0.1.0-rc.1

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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Hono server + route handlers for the OSS registry. Each route is a
3
+ * thin transport adapter over a {@link registryCore} operation:
4
+ *
5
+ * GET /healthz → liveness
6
+ * GET /search → searchArtifacts
7
+ * GET /pkg/:scope/:name/:version → readArtifact
8
+ * POST /publish → publishArtifact (bearer-gated)
9
+ * 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
+ *
14
+ * Bundle / signature / manifest serves go through {@link BundleStorage}
15
+ * directly so the operator can plug a different storage backend
16
+ * (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.
20
+ *
21
+ * 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).
25
+ */
26
+ import { Hono } from 'hono';
27
+ import { type BlueprintProbeRunner, type BundleStorage, type RegistryStorage } from '@ggui-ai/registry-core';
28
+ import type { BearerAuthn } from './authn/bearer.js';
29
+ export interface RegistryAppOptions {
30
+ readonly storage: RegistryStorage;
31
+ readonly bundleStorage: BundleStorage;
32
+ readonly authn: BearerAuthn;
33
+ /** Hostname (no protocol) for the publish-success `installCommand` field. */
34
+ readonly registryHostname: string;
35
+ /** Wall-clock provider — overridable for deterministic tests. */
36
+ readonly clock?: () => Date;
37
+ /**
38
+ * Optional blueprint runtime probe. Wire `@ggui-ai/blueprint-probe`'s
39
+ * `blueprintProbeRunner` here to enable the publish-time runtime
40
+ * gate (compile + sandboxed React render). Leaving it unset runs
41
+ * only the static gates from `checkConformance`.
42
+ */
43
+ readonly blueprintProbe?: BlueprintProbeRunner;
44
+ }
45
+ /**
46
+ * Build the hono app. Does NOT bind a port — callers (the CLI, the
47
+ * `createRegistryServer` factory, programmatic embeds) decide how to
48
+ * serve it. Returned as `Hono<{}>` so the app object can be passed to
49
+ * `@hono/node-server`'s `serve()` or any hono-compatible runtime.
50
+ */
51
+ export declare function createRegistryApp(options: RegistryAppOptions): Hono;
52
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +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"}
package/dist/server.js ADDED
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Hono server + route handlers for the OSS registry. Each route is a
3
+ * thin transport adapter over a {@link registryCore} operation:
4
+ *
5
+ * GET /healthz → liveness
6
+ * GET /search → searchArtifacts
7
+ * GET /pkg/:scope/:name/:version → readArtifact
8
+ * POST /publish → publishArtifact (bearer-gated)
9
+ * 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
+ *
14
+ * Bundle / signature / manifest serves go through {@link BundleStorage}
15
+ * directly so the operator can plug a different storage backend
16
+ * (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.
20
+ *
21
+ * 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).
25
+ */
26
+ import { Hono } from 'hono';
27
+ import { checkConformance, listArtifactVersions, publishArtifact, registerAuthorKey, readArtifact, searchArtifacts, } from '@ggui-ai/registry-core';
28
+ const IMMUTABLE_CACHE_HEADER = 'public, max-age=31536000, immutable';
29
+ /**
30
+ * Build the hono app. Does NOT bind a port — callers (the CLI, the
31
+ * `createRegistryServer` factory, programmatic embeds) decide how to
32
+ * serve it. Returned as `Hono<{}>` so the app object can be passed to
33
+ * `@hono/node-server`'s `serve()` or any hono-compatible runtime.
34
+ */
35
+ export function createRegistryApp(options) {
36
+ const { storage, bundleStorage, authn, registryHostname } = options;
37
+ const clock = options.clock ?? (() => new Date());
38
+ const blueprintProbe = options.blueprintProbe;
39
+ 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.
42
+ app.use('*', async (c, next) => {
43
+ const path = c.req.path;
44
+ const isPublishRoute = path === '/publish';
45
+ if (c.req.method === 'OPTIONS' && !isPublishRoute) {
46
+ // Preflight short-circuit for read routes.
47
+ return new Response(null, {
48
+ status: 204,
49
+ headers: {
50
+ 'Access-Control-Allow-Origin': '*',
51
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
52
+ 'Access-Control-Allow-Headers': 'content-type, authorization',
53
+ },
54
+ });
55
+ }
56
+ await next();
57
+ if (!isPublishRoute) {
58
+ c.res.headers.set('Access-Control-Allow-Origin', '*');
59
+ c.res.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
60
+ c.res.headers.set('Access-Control-Allow-Headers', 'content-type, authorization');
61
+ }
62
+ });
63
+ // ── /healthz ─────────────────────────────────────────────────────────
64
+ app.get('/healthz', (c) => c.json({ ok: true }));
65
+ // ── /search ──────────────────────────────────────────────────────────
66
+ app.get('/search', async (c) => {
67
+ const q = c.req.query();
68
+ const result = await searchArtifacts({
69
+ q: q.q,
70
+ kind: q.kind,
71
+ hook: q.hook,
72
+ tag: q.tag,
73
+ author: q.author,
74
+ limit: q.limit,
75
+ cursor: q.cursor,
76
+ sort: q.sort,
77
+ }, { storage });
78
+ if (!result.ok) {
79
+ return c.json(result.body, result.status);
80
+ }
81
+ return c.json(result.body, 200);
82
+ });
83
+ // ── /pkg/:scope/:name ────────────────────────────────────────────────
84
+ // List-versions endpoint. Returns the version timeline for an
85
+ // artifact. MUST be registered BEFORE `/pkg/:scope/:name/:version`
86
+ // so Hono's matcher doesn't shadow it (Hono routes are checked in
87
+ // registration order; a more-specific later route would still win
88
+ // since both arms have unique segment counts, but registering the
89
+ // broader pattern first keeps the wire contract obvious from the
90
+ // source order).
91
+ app.get('/pkg/:scope/:name', async (c) => {
92
+ const { scope: rawScope, name } = c.req.param();
93
+ const scope = rawScope.startsWith('@') ? rawScope : `@${rawScope}`;
94
+ const artifactId = `${scope}/${name}`;
95
+ const authHeader = c.req.header('authorization');
96
+ const verified = authn.verify(authHeader);
97
+ const result = await listArtifactVersions({ artifactId }, {
98
+ storage,
99
+ authn: verified ?? undefined,
100
+ });
101
+ return c.json(result.body, result.status);
102
+ });
103
+ // ── /pkg/:scope/:name/:version ───────────────────────────────────────
104
+ app.get('/pkg/:scope/:name/:version', async (c) => {
105
+ const { scope: rawScope, name, version } = c.req.param();
106
+ // The CLI strips the leading `@` from the scope when composing the
107
+ // URL — mirrors the cloud API Gateway path-param convention. We
108
+ // re-prepend the `@` here so the registry-core op gets the
109
+ // canonical `@scope/name` form. If the client sends an already-
110
+ // prefixed scope (curl test, dev tooling), normalize to single `@`.
111
+ const scope = rawScope.startsWith('@') ? rawScope : `@${rawScope}`;
112
+ const artifactId = `${scope}/${name}`;
113
+ const authHeader = c.req.header('authorization');
114
+ const verified = authn.verify(authHeader);
115
+ const result = await readArtifact({ artifactId, version }, {
116
+ storage,
117
+ authn: verified ?? undefined,
118
+ });
119
+ if (result.ok) {
120
+ return c.json(result.body, 200);
121
+ }
122
+ if (result.status === 410) {
123
+ return c.json(result.body, 410);
124
+ }
125
+ return c.json(result.body, result.status);
126
+ });
127
+ // ── /publish ─────────────────────────────────────────────────────────
128
+ app.post('/publish', async (c) => {
129
+ // Strict CORS — no Access-Control-Allow-Origin emitted; reject
130
+ // cross-origin Origin headers up front (browsers MUST send Origin
131
+ // on a CORS preflight). Server-to-server clients (the CLI) won't
132
+ // send Origin at all, so absence is allowed.
133
+ const origin = c.req.header('origin');
134
+ if (origin !== undefined && origin !== '') {
135
+ // Defensive: don't echo. Browsers will reject without the
136
+ // ACAO header, which is the protection we want.
137
+ }
138
+ const authHeader = c.req.header('authorization');
139
+ const verified = authn.verify(authHeader);
140
+ if (verified === null) {
141
+ return c.json({
142
+ error: 'unauthorized',
143
+ message: 'publish requires a valid bearer token in the Authorization header',
144
+ }, 401);
145
+ }
146
+ let body;
147
+ try {
148
+ body = (await c.req.json());
149
+ }
150
+ catch {
151
+ return c.json({ error: 'manifest_invalid', message: 'request body is not valid JSON' }, 400);
152
+ }
153
+ const result = await publishArtifact({
154
+ manifest: body.manifest,
155
+ bundle: body.bundle,
156
+ bundleSha384: body.bundleSha384,
157
+ signature: body.signature,
158
+ }, {
159
+ storage,
160
+ bundleStorage,
161
+ authn: verified,
162
+ clock,
163
+ registryHostname,
164
+ ...(blueprintProbe !== undefined ? { blueprintProbe } : {}),
165
+ });
166
+ return c.json(result.body, result.status);
167
+ });
168
+ // ── /author-keys ─────────────────────────────────────────────────────
169
+ // Registers a publisher Ed25519 public key under the
170
+ // bearer-authenticated subject's identity. Shares one wire contract
171
+ // with the hosted registry's author-keys endpoint.
172
+ app.post('/author-keys', async (c) => {
173
+ const authHeader = c.req.header('authorization');
174
+ const verified = authn.verify(authHeader);
175
+ 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);
180
+ }
181
+ let rawBody;
182
+ try {
183
+ rawBody = await c.req.json();
184
+ }
185
+ catch {
186
+ return c.json({ error: 'invalid_request', message: 'request body is not valid JSON' }, 400);
187
+ }
188
+ // Guard against JSON `null` + JSON primitives — Hono's
189
+ // `c.req.json()` succeeds on those (returns the value verbatim),
190
+ // and the next line would throw TypeError trying to read
191
+ // `.publicKeyBase64` off them. The hosted registry's author-keys
192
+ // endpoint applies the same guard so the two transports agree on
193
+ // the 400 wire shape.
194
+ if (rawBody === null || typeof rawBody !== 'object') {
195
+ return c.json({ error: 'invalid_request', message: 'request body must be a JSON object' }, 400);
196
+ }
197
+ const body = rawBody;
198
+ if (typeof body.publicKeyBase64 !== 'string') {
199
+ return c.json({
200
+ error: 'invalid_request',
201
+ message: '`publicKeyBase64` must be a string',
202
+ }, 400);
203
+ }
204
+ const result = await registerAuthorKey({ publicKeyBase64: body.publicKeyBase64 }, { storage, authn: verified });
205
+ return c.json(result.body, result.status);
206
+ });
207
+ // ── /conformance/check ───────────────────────────────────────────────
208
+ app.post('/conformance/check', async (c) => {
209
+ let payload;
210
+ try {
211
+ payload = (await c.req.json());
212
+ }
213
+ catch {
214
+ return c.json({ ok: false, errors: [{ code: 'manifest_invalid', message: 'request body is not valid JSON' }] }, 400);
215
+ }
216
+ const result = checkConformance(payload);
217
+ // Domain-vs-transport split: a "non-conformant" submission is a
218
+ // valid request that produced a `ok: false` body. Mirrors the cloud
219
+ // conformance handler's wire shape.
220
+ return c.json(result, 200);
221
+ });
222
+ // ── /bundles/:scope/:name/:version/bundle.js ─────────────────────────
223
+ app.get('/bundles/:scope/:name/:version/bundle.js', async (c) => {
224
+ const { scope, name, version } = c.req.param();
225
+ return serveBundle(c, bundleStorage, scope, name, version);
226
+ });
227
+ // ── /bundles/:scope/:name/:version/bundle.js.sig ─────────────────────
228
+ app.get('/bundles/:scope/:name/:version/bundle.js.sig', async (c) => {
229
+ const { scope, name, version } = c.req.param();
230
+ return serveSignature(c, bundleStorage, scope, name, version);
231
+ });
232
+ // ── /bundles/:scope/:name/:version/manifest.json ─────────────────────
233
+ app.get('/bundles/:scope/:name/:version/manifest.json', async (c) => {
234
+ const { scope, name, version } = c.req.param();
235
+ return serveManifest(c, bundleStorage, scope, name, version);
236
+ });
237
+ return app;
238
+ }
239
+ /**
240
+ * Serve the bundle bytes. `application/javascript` MIME with the
241
+ * immutable cache header. 404 on miss.
242
+ */
243
+ async function serveBundle(c, bundleStorage, rawScope, name, version) {
244
+ const scope = rawScope.startsWith('@') ? rawScope : `@${rawScope}`;
245
+ let bytes;
246
+ try {
247
+ bytes = await bundleStorage.getBundle(scope, name, version);
248
+ }
249
+ catch (err) {
250
+ return c.json(errorBody('server_error', errorMessage(err)), 500);
251
+ }
252
+ if (bytes === null) {
253
+ return c.json(errorBody('not_found', 'bundle not found'), 404);
254
+ }
255
+ // Re-wrap the Uint8Array so its backing buffer is a plain ArrayBuffer
256
+ // (hono's `c.body` typing rejects `Uint8Array<ArrayBufferLike>` because
257
+ // the SharedArrayBuffer branch is incompatible with the Web Response
258
+ // body constructor). The copy is a single contiguous allocation —
259
+ // bundles are bounded by `MAX_BUNDLE_BYTES` (5 MiB), so this is cheap.
260
+ const copy = new Uint8Array(bytes.byteLength);
261
+ copy.set(bytes);
262
+ return new Response(copy, {
263
+ status: 200,
264
+ headers: {
265
+ 'Content-Type': 'application/javascript; charset=utf-8',
266
+ 'Cache-Control': IMMUTABLE_CACHE_HEADER,
267
+ },
268
+ });
269
+ }
270
+ async function serveSignature(c, bundleStorage, rawScope, name, version) {
271
+ const scope = rawScope.startsWith('@') ? rawScope : `@${rawScope}`;
272
+ let sig;
273
+ try {
274
+ sig = await bundleStorage.getSignature(scope, name, version);
275
+ }
276
+ catch (err) {
277
+ return c.json(errorBody('server_error', errorMessage(err)), 500);
278
+ }
279
+ if (sig === null) {
280
+ return c.json(errorBody('not_found', 'signature not found'), 404);
281
+ }
282
+ c.header('Cache-Control', IMMUTABLE_CACHE_HEADER);
283
+ return c.json(sig, 200);
284
+ }
285
+ async function serveManifest(c, bundleStorage, rawScope, name, version) {
286
+ const scope = rawScope.startsWith('@') ? rawScope : `@${rawScope}`;
287
+ let manifest;
288
+ try {
289
+ manifest = await bundleStorage.getManifest(scope, name, version);
290
+ }
291
+ catch (err) {
292
+ return c.json(errorBody('server_error', errorMessage(err)), 500);
293
+ }
294
+ if (manifest === null) {
295
+ return c.json(errorBody('not_found', 'manifest not found'), 404);
296
+ }
297
+ c.header('Cache-Control', IMMUTABLE_CACHE_HEADER);
298
+ return c.json(manifest, 200);
299
+ }
300
+ function errorBody(error, message) {
301
+ return { error, message };
302
+ }
303
+ function errorMessage(err) {
304
+ if (err instanceof Error)
305
+ return err.message;
306
+ return String(err);
307
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@ggui-ai/registry-server",
3
+ "version": "0.1.0-rc.1",
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
+ "keywords": [
6
+ "ggui",
7
+ "registry",
8
+ "marketplace",
9
+ "mcp",
10
+ "hono",
11
+ "self-hosted"
12
+ ],
13
+ "license": "Apache-2.0",
14
+ "type": "module",
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "bin": {
18
+ "registry-server": "dist/cli.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "dependencies": {
32
+ "@hono/node-server": "^1.19.11",
33
+ "hono": "^4.12.8",
34
+ "@ggui-ai/artifact-manifest": "0.1.0-rc.1",
35
+ "@ggui-ai/registry-core": "0.1.0-rc.1",
36
+ "@ggui-ai/gadget-signing": "0.1.0-rc.1"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^24.0.0",
40
+ "tsx": "^4.19.0",
41
+ "typescript": "^5.0.0",
42
+ "vitest": "^3.0.0"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/ggui-ai/ggui.git",
47
+ "directory": "packages/registry-server"
48
+ },
49
+ "homepage": "https://github.com/ggui-ai/ggui/tree/main/packages/registry-server",
50
+ "bugs": {
51
+ "url": "https://github.com/ggui-ai/ggui/issues"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "engines": {
57
+ "node": ">=20.0.0"
58
+ },
59
+ "author": "ggui contributors <hello@ggui.ai>",
60
+ "scripts": {
61
+ "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
62
+ "dev": "tsx src/cli.ts",
63
+ "start": "node dist/cli.js",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest",
67
+ "clean": "rm -rf dist"
68
+ }
69
+ }