@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,152 @@
1
+ /**
2
+ * Filesystem-backed {@link BundleStorage} for the OSS registry server.
3
+ *
4
+ * Blob layout:
5
+ *
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
9
+ *
10
+ * Scope is written verbatim (`@my-org/...`) because the leading `@`
11
+ * 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).
15
+ *
16
+ * ## Path-traversal defense
17
+ *
18
+ * Every {scope, name, version} input is checked for `..`, `/`
19
+ * (except the leading scope `@` which contains no `/`), and `\\`.
20
+ * Scopes contain a single leading `@` then `[a-z0-9-]+` per the
21
+ * manifest schema; `name` is kebab-case; `version` is semver. We reject
22
+ * defensively at the storage layer in addition to the schema enforcement.
23
+ *
24
+ * ## Immutability
25
+ *
26
+ * Re-puts overwrite the file — the per-version row immutability
27
+ * invariant on {@link RegistryStorage.putArtifactVersionIfAbsent}
28
+ * prevents true re-publishes from ever reaching this layer, so
29
+ * overwrite-on-collision is safe (in practice unreachable).
30
+ *
31
+ * ## Protocol & Contract Bar
32
+ *
33
+ * Inherits the {@link BundleStorage} contract verbatim. The contract
34
+ * test exercises bundle / signature / manifest round-trips + URL
35
+ * composition + missing-blob reads (returns null, never throws).
36
+ */
37
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
38
+ import { join } from 'node:path';
39
+ import { isGadgetSignature } from '@ggui-ai/gadget-signing';
40
+ export function createFilesystemBundleStorage(options) {
41
+ const bundlesRoot = join(options.root, 'bundles');
42
+ const bundleHost = stripTrailingSlash(options.bundleHost);
43
+ const dirFor = (scope, name, version) => {
44
+ rejectTraversal(scope, 'scope');
45
+ rejectTraversal(name, 'name');
46
+ rejectTraversal(version, 'version');
47
+ return join(bundlesRoot, scope, name, version);
48
+ };
49
+ return {
50
+ async putBundle(scope, name, version, bytes) {
51
+ const dir = dirFor(scope, name, version);
52
+ await mkdir(dir, { recursive: true });
53
+ await writeFile(join(dir, 'bundle.js'), bytes);
54
+ return this.bundleUrl(scope, name, version);
55
+ },
56
+ async getBundle(scope, name, version) {
57
+ const path = join(dirFor(scope, name, version), 'bundle.js');
58
+ return readFileOrNull(path);
59
+ },
60
+ async putSignature(scope, name, version, signature) {
61
+ const dir = dirFor(scope, name, version);
62
+ await mkdir(dir, { recursive: true });
63
+ await writeFile(join(dir, 'bundle.js.sig'), JSON.stringify(signature, null, 2), 'utf8');
64
+ return this.signatureUrl(scope, name, version);
65
+ },
66
+ async getSignature(scope, name, version) {
67
+ const path = join(dirFor(scope, name, version), 'bundle.js.sig');
68
+ const text = await readTextOrNull(path);
69
+ if (text === null)
70
+ return null;
71
+ // Runtime-validate the parsed shape against the `GadgetSignature`
72
+ // union so a malformed on-disk signature can't slip through.
73
+ // Force-narrowing to `Ed25519Signature` would compile clean but
74
+ // would silently drop the sigstore-only fields of a sigstore
75
+ // signature, since both signature kinds persist here.
76
+ const parsed = JSON.parse(text);
77
+ if (!isGadgetSignature(parsed))
78
+ return null;
79
+ return parsed;
80
+ },
81
+ async putManifest(scope, name, version, manifest) {
82
+ const dir = dirFor(scope, name, version);
83
+ await mkdir(dir, { recursive: true });
84
+ await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
85
+ return this.manifestUrl(scope, name, version);
86
+ },
87
+ async getManifest(scope, name, version) {
88
+ const path = join(dirFor(scope, name, version), 'manifest.json');
89
+ const text = await readTextOrNull(path);
90
+ return text === null ? null : JSON.parse(text);
91
+ },
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`;
97
+ },
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`;
103
+ },
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
+ },
110
+ };
111
+ }
112
+ /**
113
+ * Defensive path-traversal reject. `scope`, `name`, `version` are all
114
+ * regex-validated by the manifest schema at parse time. This is the
115
+ * second wall — if a caller bypasses the schema, the storage layer
116
+ * still rejects. Scope is allowed to contain a single `/` ONLY if it's
117
+ * the first character after `@` AND the value is exactly the canonical
118
+ * scope form — but scopes are `@ns` (no `/`), so `/` is unambiguously
119
+ * rejected here.
120
+ */
121
+ function rejectTraversal(value, fieldName) {
122
+ if (value.includes('..') || value.includes('/') || value.includes('\\')) {
123
+ throw new Error(`path-traversal: filesystem bundle storage rejects ${fieldName}=${JSON.stringify(value)} (contains "..", "/", or "\\")`);
124
+ }
125
+ }
126
+ async function readFileOrNull(path) {
127
+ try {
128
+ const buf = await readFile(path);
129
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
130
+ }
131
+ catch (err) {
132
+ if (isErrnoException(err) && err.code === 'ENOENT')
133
+ return null;
134
+ throw err;
135
+ }
136
+ }
137
+ async function readTextOrNull(path) {
138
+ try {
139
+ return await readFile(path, 'utf8');
140
+ }
141
+ catch (err) {
142
+ if (isErrnoException(err) && err.code === 'ENOENT')
143
+ return null;
144
+ throw err;
145
+ }
146
+ }
147
+ function stripTrailingSlash(s) {
148
+ return s.endsWith('/') ? s.slice(0, -1) : s;
149
+ }
150
+ function isErrnoException(err) {
151
+ return err instanceof Error && typeof err.code === 'string';
152
+ }
@@ -0,0 +1,7 @@
1
+ import type { RegistryStorage } from '@ggui-ai/registry-core';
2
+ export interface FilesystemRegistryStorageOptions {
3
+ /** Absolute path to the state root. Must already exist or be `mkdir -p`-able. */
4
+ readonly root: string;
5
+ }
6
+ export declare function createFilesystemRegistryStorage(options: FilesystemRegistryStorageOptions): RegistryStorage;
7
+ //# sourceMappingURL=filesystem-registry-storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filesystem-registry-storage.d.ts","sourceRoot":"","sources":["../src/filesystem-registry-storage.ts"],"names":[],"mappings":"AA+DA,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,CAuLjB"}
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Filesystem-backed {@link RegistryStorage} for the OSS registry
3
+ * server. Row-per-file under `<root>/state/`:
4
+ *
5
+ * <root>/state/plugins/<encoded artifactId>.json → ArtifactsMetadataRow
6
+ * <root>/state/versions/<encoded artifactId>__<v>.json → ArtifactVersionRow
7
+ * <root>/state/author-keys/<subject>__<keyId>.json → AuthorKeyRow
8
+ *
9
+ * The `__` separator is chosen because npm pkg names + semvers never
10
+ * contain it (semver disallows underscores entirely; npm names disallow
11
+ * leading underscores). `artifactId` contains `/` so we URL-encode the
12
+ * whole id; encoded form is filename-safe + reversible.
13
+ *
14
+ * ## Atomic conflict detection
15
+ *
16
+ * {@link putArtifactVersionIfAbsent} uses `fs.writeFile(..., { flag: 'wx' })`
17
+ * — the open(2) `O_EXCL` flag. `wx` fails with `EEXIST` if the file is
18
+ * already present, which we map to `{ ok: false, reason: 'version_exists' }`.
19
+ * Mirrors DDB's `ConditionExpression: attribute_not_exists(...)`. The
20
+ * happy-path write is atomic at the inode level — partial writes on
21
+ * crash leave the file absent rather than half-populated.
22
+ *
23
+ * ## Path-traversal defense
24
+ *
25
+ * Every `artifactId` is URL-encoded before becoming a filename, so
26
+ * `../` characters in arbitrary input are turned into `%2E%2E%2F` and
27
+ * cannot escape the directory. We also defensively reject any input
28
+ * containing literal `..`, `/`, or `\\` at the row-key boundary even
29
+ * though the manifest schema regex already rejects them — defense in
30
+ * depth, the cost is two `String#includes` calls per write.
31
+ *
32
+ * ## Scan semantics
33
+ *
34
+ * {@link scanArtifacts} reads every metadata file, applies the same
35
+ * AND-composition filter the memory impl does, and slices the result
36
+ * to `[offset, offset+limit)`. Cursor is an integer offset encoded as
37
+ * a base-10 string — opaque to consumers, identical to memory's
38
+ * encoding. For typical OSS deployments (< 10k artifacts) the full
39
+ * scan is sub-millisecond; large deployments should migrate to the
40
+ * cloud DDB adapter.
41
+ *
42
+ * ## Protocol & Contract Bar
43
+ *
44
+ * Inherits the {@link RegistryStorage} interface contract verbatim.
45
+ * Additional impl-specific obligations:
46
+ *
47
+ * **Obligations:**
48
+ * - Every method MUST be safe to call before the underlying directory
49
+ * exists. The impl `mkdir -p`s on first write; reads return `null`
50
+ * on `ENOENT` at any level.
51
+ *
52
+ * **Failure mode:**
53
+ * - Transport-level failures (disk full, permission denied) throw.
54
+ * `publishArtifact` wraps and returns 500. `ENOENT` on a read is
55
+ * NOT a transport failure — it's a "row absent" signal, returns `null`.
56
+ *
57
+ * **Observable violation:**
58
+ * - Contract test `filesystem-registry-storage.test.ts` runs the full
59
+ * {@link registryStorageContract} suite against a fresh tmpdir per
60
+ * test. Plus path-traversal rejection tests local to this file.
61
+ */
62
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
63
+ import { dirname, join } from 'node:path';
64
+ const DEFAULT_LIMIT = 50;
65
+ const MAX_LIMIT = 200;
66
+ export function createFilesystemRegistryStorage(options) {
67
+ const stateRoot = join(options.root, 'state');
68
+ const pluginsDir = join(stateRoot, 'plugins');
69
+ const versionsDir = join(stateRoot, 'versions');
70
+ const authorKeysDir = join(stateRoot, 'author-keys');
71
+ // Content-addressed compiled-blob rows. Filenames are the 64-char
72
+ // hex digest + `.json`. The digest itself is filename-safe (hex
73
+ // chars only), so no URL-encoding is needed.
74
+ const compiledBlobsDir = join(stateRoot, 'compiled-blobs');
75
+ const compiledBlobPath = (compiledDigest) => {
76
+ rejectTraversal(compiledDigest, 'compiledDigest');
77
+ return join(compiledBlobsDir, `${compiledDigest}.json`);
78
+ };
79
+ const metadataPath = (artifactId) => join(pluginsDir, `${encodeRowKey(artifactId)}.json`);
80
+ const versionPath = (artifactId, version) => {
81
+ rejectTraversal(version, 'version');
82
+ return join(versionsDir, `${encodeRowKey(artifactId)}__${encodeRowKey(version)}.json`);
83
+ };
84
+ const authorKeyPath = (subject, keyId) => {
85
+ rejectTraversal(subject, 'subject');
86
+ rejectTraversal(keyId, 'keyId');
87
+ return join(authorKeysDir, `${encodeRowKey(subject)}__${encodeRowKey(keyId)}.json`);
88
+ };
89
+ return {
90
+ async getArtifactMetadata(artifactId) {
91
+ return readJsonOrNull(metadataPath(artifactId));
92
+ },
93
+ async putArtifactMetadata(row) {
94
+ await ensureDir(pluginsDir);
95
+ await writeJson(metadataPath(row.artifactId), row);
96
+ },
97
+ async scanArtifacts(filter) {
98
+ const rows = await readAllMetadata(pluginsDir);
99
+ const filtered = rows.filter((row) => rowMatchesFilter(row, filter));
100
+ const limit = clampLimit(filter.limit);
101
+ const offset = parseCursor(filter.cursor);
102
+ const page = filtered.slice(offset, offset + limit);
103
+ const nextOffset = offset + page.length;
104
+ const nextCursor = nextOffset < filtered.length ? String(nextOffset) : undefined;
105
+ return { rows: page, nextCursor };
106
+ },
107
+ async getArtifactVersion(artifactId, version) {
108
+ return readJsonOrNull(versionPath(artifactId, version));
109
+ },
110
+ async listArtifactVersions(artifactId) {
111
+ // Walk the versions/ directory; pick filenames whose URL-encoded
112
+ // artifactId prefix matches. The `__` separator is safe because
113
+ // semver versions never contain `__` (semver: ASCII alphanum +
114
+ // `.` + `-`) and the URL-encoded artifactId can't contain `__`
115
+ // either (encoder maps `_` → `_`, never doubles it).
116
+ const prefix = `${encodeRowKey(artifactId)}__`;
117
+ let entries;
118
+ try {
119
+ entries = await readdir(versionsDir);
120
+ }
121
+ catch (err) {
122
+ if (isErrnoException(err) && err.code === 'ENOENT')
123
+ return [];
124
+ throw err;
125
+ }
126
+ const matches = [];
127
+ for (const entry of entries) {
128
+ if (!entry.startsWith(prefix) || !entry.endsWith('.json'))
129
+ continue;
130
+ const row = await readJsonOrNull(join(versionsDir, entry));
131
+ if (row !== null)
132
+ matches.push(row);
133
+ }
134
+ return matches;
135
+ },
136
+ async putArtifactVersionIfAbsent(row) {
137
+ await ensureDir(versionsDir);
138
+ const path = versionPath(row.artifactId, row.version);
139
+ try {
140
+ await writeFile(path, JSON.stringify(row, null, 2), {
141
+ flag: 'wx',
142
+ encoding: 'utf8',
143
+ });
144
+ return { ok: true };
145
+ }
146
+ catch (err) {
147
+ if (isErrnoException(err) && err.code === 'EEXIST') {
148
+ return { ok: false, reason: 'version_exists' };
149
+ }
150
+ throw err;
151
+ }
152
+ },
153
+ async yankArtifactVersion(artifactId, version) {
154
+ const path = versionPath(artifactId, version);
155
+ const existing = await readJsonOrNull(path);
156
+ if (existing === null)
157
+ return;
158
+ const yanked = { ...existing, yanked: true };
159
+ await writeJson(path, yanked);
160
+ },
161
+ async getCompiledBlob(compiledDigest) {
162
+ return readJsonOrNull(compiledBlobPath(compiledDigest));
163
+ },
164
+ async commitVersionAndBlob(versionRow, blobRow) {
165
+ // Atomic write — the filesystem impl gets its atomicity from the
166
+ // JS event loop (single-writer assumption; the server runs one
167
+ // process). The version-row write goes first using `wx`
168
+ // (O_EXCL); on EEXIST we surface `version_exists` WITHOUT
169
+ // touching the blob row. If the version write succeeds, we then
170
+ // either INSERT a new blob row or read+rewrite the existing
171
+ // one's refCount.
172
+ await ensureDir(versionsDir);
173
+ await ensureDir(compiledBlobsDir);
174
+ const vPath = versionPath(versionRow.artifactId, versionRow.version);
175
+ try {
176
+ await writeFile(vPath, JSON.stringify(versionRow, null, 2), {
177
+ flag: 'wx',
178
+ encoding: 'utf8',
179
+ });
180
+ }
181
+ catch (err) {
182
+ if (isErrnoException(err) && err.code === 'EEXIST') {
183
+ return { ok: false, reason: 'version_exists' };
184
+ }
185
+ throw err;
186
+ }
187
+ // Version row is durable. Now the blob row — INSERT-or-bump.
188
+ const blobFsPath = compiledBlobPath(blobRow.compiledDigest);
189
+ try {
190
+ await writeFile(blobFsPath, JSON.stringify(blobRow, null, 2), {
191
+ flag: 'wx',
192
+ encoding: 'utf8',
193
+ });
194
+ return { ok: true, mode: 'new-blob' };
195
+ }
196
+ catch (err) {
197
+ if (isErrnoException(err) && err.code === 'EEXIST') {
198
+ // Dedup — read the existing row and bump refCount.
199
+ const existing = await readJsonOrNull(blobFsPath);
200
+ if (existing === null) {
201
+ // Vanished between EEXIST and read — treat as race; the
202
+ // single-writer OSS server doesn't have a delete path so
203
+ // this is a corruption signal we surface as a throw.
204
+ throw new Error(`commitVersionAndBlob: blob row vanished between EEXIST and read for compiledDigest=${blobRow.compiledDigest}`);
205
+ }
206
+ const updated = {
207
+ ...existing,
208
+ refCount: existing.refCount + 1,
209
+ };
210
+ await writeJson(blobFsPath, updated);
211
+ return { ok: true, mode: 'dedup' };
212
+ }
213
+ throw err;
214
+ }
215
+ },
216
+ async getAuthorKey(subject, keyId) {
217
+ return readJsonOrNull(authorKeyPath(subject, keyId));
218
+ },
219
+ async putAuthorKey(row) {
220
+ await ensureDir(authorKeysDir);
221
+ await writeJson(authorKeyPath(row.subject, row.keyId), row);
222
+ },
223
+ async listAuthorKeys(subject) {
224
+ const prefix = `${encodeRowKey(subject)}__`;
225
+ let entries;
226
+ try {
227
+ entries = await readdir(authorKeysDir);
228
+ }
229
+ catch (err) {
230
+ if (isErrnoException(err) && err.code === 'ENOENT')
231
+ return [];
232
+ throw err;
233
+ }
234
+ const matches = [];
235
+ for (const entry of entries) {
236
+ if (!entry.startsWith(prefix) || !entry.endsWith('.json'))
237
+ continue;
238
+ const row = await readJsonOrNull(join(authorKeysDir, entry));
239
+ if (row !== null)
240
+ matches.push(row);
241
+ }
242
+ return matches;
243
+ },
244
+ };
245
+ }
246
+ /**
247
+ * URL-encode the key. Filename-safe + reversible. `/` becomes `%2F`,
248
+ * `..` becomes `..` (allowed because the manifest schema rejects it
249
+ * before we ever get here, AND we `rejectTraversal` defensively) but
250
+ * even if it slipped through, the encoded form is what gets joined to
251
+ * the directory — `..` as a literal filename segment is just a file
252
+ * named `..`, not a directory traversal.
253
+ */
254
+ function encodeRowKey(key) {
255
+ return encodeURIComponent(key);
256
+ }
257
+ /**
258
+ * Defensive path-traversal reject. The manifest schema regex blocks
259
+ * `..`, `/`, and `\\` at parse time. This is a second wall — if a
260
+ * caller bypasses the schema (programmatic injection from a fuzzed
261
+ * test, future row type added without schema coverage), the storage
262
+ * layer still rejects.
263
+ */
264
+ function rejectTraversal(value, fieldName) {
265
+ if (value.includes('..') || value.includes('/') || value.includes('\\')) {
266
+ throw new Error(`path-traversal: filesystem storage rejects ${fieldName}=${JSON.stringify(value)} (contains "..", "/", or "\\")`);
267
+ }
268
+ }
269
+ async function ensureDir(dir) {
270
+ await mkdir(dir, { recursive: true });
271
+ }
272
+ async function readJsonOrNull(path) {
273
+ try {
274
+ const text = await readFile(path, 'utf8');
275
+ return JSON.parse(text);
276
+ }
277
+ catch (err) {
278
+ if (isErrnoException(err) && err.code === 'ENOENT')
279
+ return null;
280
+ throw err;
281
+ }
282
+ }
283
+ async function writeJson(path, value) {
284
+ await mkdir(dirname(path), { recursive: true });
285
+ await writeFile(path, JSON.stringify(value, null, 2), 'utf8');
286
+ }
287
+ async function readAllMetadata(pluginsDir) {
288
+ let entries;
289
+ try {
290
+ entries = await readdir(pluginsDir);
291
+ }
292
+ catch (err) {
293
+ if (isErrnoException(err) && err.code === 'ENOENT')
294
+ return [];
295
+ throw err;
296
+ }
297
+ const rows = [];
298
+ for (const entry of entries) {
299
+ if (!entry.endsWith('.json'))
300
+ continue;
301
+ const row = await readJsonOrNull(join(pluginsDir, entry));
302
+ if (row !== null)
303
+ rows.push(row);
304
+ }
305
+ return rows;
306
+ }
307
+ function clampLimit(limit) {
308
+ if (limit === undefined)
309
+ return DEFAULT_LIMIT;
310
+ if (!Number.isInteger(limit))
311
+ return DEFAULT_LIMIT;
312
+ if (limit < 1)
313
+ return 1;
314
+ if (limit > MAX_LIMIT)
315
+ return MAX_LIMIT;
316
+ return limit;
317
+ }
318
+ function parseCursor(cursor) {
319
+ if (cursor === undefined || cursor.length === 0)
320
+ return 0;
321
+ const n = Number.parseInt(cursor, 10);
322
+ if (!Number.isInteger(n) || n < 0)
323
+ return 0;
324
+ return n;
325
+ }
326
+ /**
327
+ * Mirrors {@link inMemoryRegistryStorage}'s filter — same
328
+ * AND-composition, same case-insensitive substring match on q, same
329
+ * publisher-sub OR author-name match. Kept inline so the filesystem
330
+ * impl doesn't depend on a private helper from registry-core.
331
+ */
332
+ function rowMatchesFilter(row, q) {
333
+ if (q.kind !== undefined && row.kind !== q.kind)
334
+ return false;
335
+ if (q.hook !== undefined && row.hook !== q.hook)
336
+ return false;
337
+ if (q.tag !== undefined) {
338
+ if (row.tags === undefined || !row.tags.includes(q.tag))
339
+ return false;
340
+ }
341
+ if (q.author !== undefined) {
342
+ const matchesSub = row.publishedBy === q.author;
343
+ const matchesName = row.authorName !== undefined &&
344
+ row.authorName.toLowerCase().includes(q.author.toLowerCase());
345
+ if (!matchesSub && !matchesName)
346
+ return false;
347
+ }
348
+ if (q.q !== undefined) {
349
+ const needle = q.q.toLowerCase();
350
+ const slashIdx = row.artifactId.indexOf('/');
351
+ const namePart = slashIdx >= 0 ? row.artifactId.slice(slashIdx + 1) : row.artifactId;
352
+ const inName = namePart.toLowerCase().includes(needle);
353
+ const inDescription = row.description !== undefined && row.description.toLowerCase().includes(needle);
354
+ const inTags = row.tags !== undefined && row.tags.some((t) => t.toLowerCase().includes(needle));
355
+ if (!inName && !inDescription && !inTags)
356
+ return false;
357
+ }
358
+ return true;
359
+ }
360
+ function isErrnoException(err) {
361
+ return err instanceof Error && typeof err.code === 'string';
362
+ }
@@ -0,0 +1,51 @@
1
+ import type { BlueprintProbeRunner, BundleStorage, RegistryStorage } from '@ggui-ai/registry-core';
2
+ import type { BearerAuthn } from './authn/bearer.js';
3
+ export { createBearerAuthn, type BearerAuthn, type CreateBearerAuthnOptions } from './authn/bearer.js';
4
+ export { createFilesystemBundleStorage, type FilesystemBundleStorageOptions, } from './filesystem-bundle-storage.js';
5
+ export { createFilesystemRegistryStorage, type FilesystemRegistryStorageOptions, } from './filesystem-registry-storage.js';
6
+ export { createRegistryApp, type RegistryAppOptions } from './server.js';
7
+ export interface CreateRegistryServerOptions {
8
+ readonly storage: RegistryStorage;
9
+ readonly bundleStorage: BundleStorage;
10
+ readonly authn: BearerAuthn;
11
+ /** Default: `'0.0.0.0'`. */
12
+ readonly host?: string;
13
+ /** Default: `9001`. Pass `0` to let the OS assign a free port. */
14
+ readonly port?: number;
15
+ /**
16
+ * Public URL prefix the install CLI / iframe runtime fetches bundles
17
+ * from. Typically `http://localhost:<port>` for local dev. Required
18
+ * because the server cannot reliably guess its own public address
19
+ * (operator may front it with a reverse proxy at a different host).
20
+ * No trailing slash.
21
+ */
22
+ readonly bundleHost: string;
23
+ /**
24
+ * Hostname (no protocol) embedded into the `installCommand` field of
25
+ * `POST /publish` responses. Example: `localhost:9001`, or
26
+ * `registry.example.com` behind a reverse proxy.
27
+ */
28
+ readonly registryHostname: string;
29
+ /** Wall-clock provider — overridable for deterministic tests. */
30
+ readonly clock?: () => Date;
31
+ /**
32
+ * Optional blueprint runtime probe. Wire `@ggui-ai/blueprint-probe`'s
33
+ * `blueprintProbeRunner` to enable the publish-time runtime gate.
34
+ * Forwarded to `createRegistryApp`.
35
+ *
36
+ * Intentionally opt-in: `vm.runInContext` is NOT a security boundary
37
+ * — sandboxed code can escape it (e.g. via
38
+ * `require('react').useState.constructor`). Wire this only when the
39
+ * trust boundary is the caller's process — e.g. CLI-local checks or
40
+ * e2e fixtures, not unauthenticated public publish endpoints.
41
+ */
42
+ readonly blueprintProbe?: BlueprintProbeRunner;
43
+ }
44
+ export interface RegistryServerHandle {
45
+ /** The port the server is actually bound to. Resolves after {@link start}. */
46
+ readonly actualPort: number;
47
+ start(): Promise<void>;
48
+ stop(): Promise<void>;
49
+ }
50
+ export declare function createRegistryServer(options: CreateRegistryServerOptions): RegistryServerHandle;
51
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `@ggui-ai/registry-server` — self-hostable HTTP server for the ggui
3
+ * marketplace registry.
4
+ *
5
+ * Wraps `@ggui-ai/registry-core` with hono + filesystem storage +
6
+ * bearer-token auth. The hosted registry uses the same registry-core
7
+ * ops behind a different transport (a managed gateway, DynamoDB,
8
+ * object storage, and hosted auth).
9
+ *
10
+ * Two entry points:
11
+ *
12
+ * - `createRegistryApp({...})` — hono app object only (no port binding).
13
+ * Use this when embedding inside an
14
+ * existing hono / express / next.js server.
15
+ *
16
+ * - `createRegistryServer({...})` — boots a `@hono/node-server` on a port.
17
+ * Returns `{ start, stop, actualPort }`.
18
+ * Use this for `npx @ggui-ai/registry-server`
19
+ * + e2e fixtures + standalone deployments.
20
+ */
21
+ import { serve } from '@hono/node-server';
22
+ import { createRegistryApp } from './server.js';
23
+ export { createBearerAuthn } from './authn/bearer.js';
24
+ export { createFilesystemBundleStorage, } from './filesystem-bundle-storage.js';
25
+ export { createFilesystemRegistryStorage, } from './filesystem-registry-storage.js';
26
+ export { createRegistryApp } from './server.js';
27
+ export function createRegistryServer(options) {
28
+ const app = createRegistryApp({
29
+ storage: options.storage,
30
+ bundleStorage: options.bundleStorage,
31
+ authn: options.authn,
32
+ registryHostname: options.registryHostname,
33
+ clock: options.clock,
34
+ ...(options.blueprintProbe !== undefined
35
+ ? { blueprintProbe: options.blueprintProbe }
36
+ : {}),
37
+ });
38
+ const host = options.host ?? '0.0.0.0';
39
+ const requestedPort = options.port ?? 9001;
40
+ // `actualPort` is mutable until `start()` resolves with the bound
41
+ // port — we expose a getter to keep the interface read-only.
42
+ let actualPort = requestedPort;
43
+ let server = null;
44
+ return {
45
+ get actualPort() {
46
+ return actualPort;
47
+ },
48
+ async start() {
49
+ if (server !== null)
50
+ return; // idempotent
51
+ await new Promise((resolve, reject) => {
52
+ try {
53
+ server = serve({ fetch: app.fetch, hostname: host, port: requestedPort }, (info) => {
54
+ actualPort = info.port;
55
+ resolve();
56
+ });
57
+ }
58
+ catch (err) {
59
+ reject(err);
60
+ }
61
+ });
62
+ },
63
+ async stop() {
64
+ if (server === null)
65
+ return;
66
+ const s = server;
67
+ server = null;
68
+ await new Promise((resolve, reject) => {
69
+ s.close((err) => {
70
+ if (err)
71
+ reject(err);
72
+ else
73
+ resolve();
74
+ });
75
+ });
76
+ },
77
+ };
78
+ }