@celilo/e2e 0.9.2-alpha.0 → 0.9.3

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.
@@ -1,572 +0,0 @@
1
- /**
2
- * Celilo Module Registry Server
3
- *
4
- * Implements the Cargo sparse registry protocol:
5
- * GET /index/config.json → sparse protocol config
6
- * GET /index/{path} → sparse index file (NDJSON)
7
- * GET /api/v1/modules → search modules
8
- * GET /api/v1/modules/{name} → module metadata
9
- * GET /api/v1/modules/{name}/{ver}/download → download .netapp
10
- * PUT /api/v1/modules/new → publish (token auth)
11
- * DELETE /api/v1/modules/{name}/{ver}/yank → yank
12
- * PUT /api/v1/modules/{name}/{ver}/unyank → unyank
13
- *
14
- * All routes can be prefixed with PATH_PREFIX (e.g. "/registry").
15
- *
16
- * When BOOTSTRAP_MODULES_DIR is set, modules discovered there are exposed
17
- * through the same endpoints — see bootstrap.ts. Bootstrap entries are
18
- * shadowed by real publishes (same name): the stored package wins so tests
19
- * can publish-then-import against a live registry.
20
- */
21
-
22
- import { join } from 'node:path';
23
- import { TokenAuth } from './auth';
24
- import {
25
- type BootstrapEntry,
26
- bootstrapIndexEntry,
27
- packageBootstrapModule,
28
- scanBootstrapDir,
29
- scanUploadsDir,
30
- } from './bootstrap';
31
- import { landingResponse } from './landing';
32
- import { type RateLimiter, clientIp, createRateLimiter } from './rate-limit';
33
- import { ScopedTokenStore, fileScopedTokenPersistence } from './scoped-token-store';
34
- import { type IndexEntry, RegistryStorage } from './storage';
35
- import { isValidName, isValidVersion, validateNameAndVersion } from './validation';
36
-
37
- /** Max total publish body size. Real modules are a few MB; 100MB is generous. */
38
- const MAX_PUBLISH_BYTES = 100 * 1024 * 1024;
39
-
40
- /** Max JSON metadata blob size inside a publish body. Real metadata is ~hundreds of bytes. */
41
- const MAX_META_BYTES = 64 * 1024;
42
-
43
- /**
44
- * RFC 6266-safe Content-Disposition value. Strips everything but
45
- * `[A-Za-z0-9_.+-]` for the quoted form (defense-in-depth: even if the
46
- * upstream name/version validator is bypassed, the header cannot inject
47
- * CRLF or break out of the quoted-string), and includes the original
48
- * UTF-8 value via `filename*=UTF-8''...` for compliant clients.
49
- */
50
- function contentDispositionAttachment(filename: string): string {
51
- const quotedSafe = filename.replace(/[^A-Za-z0-9_.+\-]/g, '_');
52
- return `attachment; filename="${quotedSafe}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
53
- }
54
-
55
- interface ServerOptions {
56
- dataDir: string;
57
- port: number;
58
- pathPrefix: string;
59
- publicUrl: string;
60
- /** Scanned for module source dirs (with manifest.yml), packaged on demand. */
61
- bootstrapModulesDir?: string;
62
- /** Scanned for pre-built .netapp files, served directly. */
63
- bootstrapUploadsDir?: string;
64
- bootstrapCacheDir?: string;
65
- auth: TokenAuth;
66
- /**
67
- * Optional override for the write-endpoint rate limiter; defaults to
68
- * 30 events per IP per minute, which is generous for a registry (real
69
- * publishes happen on the order of once per feature) but tight enough
70
- * to slow a bad actor to a crawl.
71
- */
72
- rateLimiter?: RateLimiter;
73
- /**
74
- * Store of minted, per-repo package-scoped tokens (build-bus Phase 3,
75
- * ISS-0140). Defaults to a JSON file in `dataDir`. Its persisted hashes are
76
- * loaded into `auth` at startup so minted tokens survive a restart.
77
- */
78
- scopedTokenStore?: ScopedTokenStore;
79
- }
80
-
81
- export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve> {
82
- const { dataDir, port, pathPrefix, publicUrl, auth } = options;
83
- const storage = new RegistryStorage(dataDir);
84
- const scopedTokenStore =
85
- options.scopedTokenStore ??
86
- new ScopedTokenStore(fileScopedTokenPersistence(join(dataDir, 'scoped-tokens.json')));
87
- // Load persisted minted tokens into the in-memory authorizer.
88
- for (const entry of scopedTokenStore.list()) {
89
- auth.addHashed(entry.hash, entry.scope);
90
- }
91
- const bootstrapCacheDir = options.bootstrapCacheDir ?? join(dataDir, '.bootstrap-cache');
92
- const rateLimiter = options.rateLimiter ?? createRateLimiter({ max: 30, windowMs: 60_000 });
93
-
94
- /**
95
- * Rescan every request so .netapps dropped into bootstrapUploadsDir at
96
- * runtime (via `docker cp` from the e2e harness) are picked up without
97
- * restarting the server. Source dirs are less volatile so we only rescan
98
- * them when uploads also get rescanned — the cost is a readdir or two per
99
- * request, negligible in the e2e fixture context where this runs.
100
- *
101
- * In production neither env var is set and this function returns an empty
102
- * map without touching the disk.
103
- */
104
- function resolveBootstrap(): Map<string, BootstrapEntry> {
105
- const merged = new Map<string, BootstrapEntry>();
106
- if (options.bootstrapModulesDir) {
107
- for (const [name, entry] of scanBootstrapDir(options.bootstrapModulesDir)) {
108
- merged.set(name, entry);
109
- }
110
- }
111
- if (options.bootstrapUploadsDir) {
112
- // Uploads shadow source-dir entries for the same name.
113
- for (const [name, entry] of scanUploadsDir(options.bootstrapUploadsDir)) {
114
- merged.set(name, entry);
115
- }
116
- }
117
- return merged;
118
- }
119
-
120
- const sparseConfig = {
121
- dl: `${publicUrl}/api/v1/modules/{name}/{version}/download`,
122
- api: publicUrl,
123
- };
124
-
125
- function err(detail: string, status = 400): Response {
126
- return Response.json({ errors: [{ detail }] }, { status });
127
- }
128
-
129
- function notFound(): Response {
130
- return err('Not found', 404);
131
- }
132
-
133
- function unauthorized(): Response {
134
- return err('Unauthorized — provide a valid publish token in the Authorization header', 401);
135
- }
136
-
137
- /** True when the request's token may publish/yank `pkg` (admin or exact scope). */
138
- function authorizePackage(req: Request, pkg: string): boolean {
139
- return auth.authorize(req.headers.get('Authorization') ?? '', pkg);
140
- }
141
-
142
- /** True when the request carries an admin (`*`-scoped) token — required to mint. */
143
- function authorizeAdmin(req: Request): boolean {
144
- return auth.isAdmin(req.headers.get('Authorization') ?? '');
145
- }
146
-
147
- /**
148
- * Rate-limit check for write endpoints. Returns a 429 response when
149
- * exceeded (with Retry-After), null when allowed.
150
- */
151
- function rateLimitOrNull(
152
- req: Request,
153
- srv: { requestIP(r: Request): { address: string } | null },
154
- ): Response | null {
155
- const ip = clientIp(req, srv);
156
- const result = rateLimiter.take(ip);
157
- if (result.ok) return null;
158
- return new Response(
159
- JSON.stringify({ errors: [{ detail: 'Too many write requests — try again later' }] }),
160
- {
161
- status: 429,
162
- headers: {
163
- 'Content-Type': 'application/json',
164
- 'Retry-After': String(result.retryAfterSec),
165
- },
166
- },
167
- );
168
- }
169
-
170
- /**
171
- * Resolve a module name to (a) its published entries and (b) whether a
172
- * bootstrap fallback exists. Real publishes take precedence — once a name
173
- * has any published version, bootstrap for that name is ignored, so tests
174
- * that publish namecheap@1.0.0+1 don't keep picking up the repo's source.
175
- *
176
- * Caller MUST have validated `name` first. We also guard here as a cheap
177
- * backstop since the function is reachable from several handlers.
178
- */
179
- function resolveEntries(name: string): IndexEntry[] {
180
- if (!isValidName(name)) return [];
181
- const published = storage.readIndex(name);
182
- if (published.length > 0) return published;
183
- const b = resolveBootstrap().get(name);
184
- return b ? [bootstrapIndexEntry(b)] : [];
185
- }
186
-
187
- async function serveBootstrapDownload(entry: BootstrapEntry): Promise<Response> {
188
- const netappPath = await packageBootstrapModule(entry, bootstrapCacheDir);
189
- return new Response(Bun.file(netappPath), {
190
- headers: {
191
- 'Content-Type': 'application/octet-stream',
192
- 'Content-Disposition': contentDispositionAttachment(`${entry.name}.netapp`),
193
- },
194
- });
195
- }
196
-
197
- async function handlePublish(req: Request): Promise<Response> {
198
- // Refuse to even buffer oversized publishes. Content-Length can be spoofed
199
- // but a hostile client that lies to get past this still has to stream
200
- // through Caddy's per-connection read limits; defense-in-depth.
201
- const declaredLength = Number(req.headers.get('Content-Length'));
202
- if (Number.isFinite(declaredLength) && declaredLength > MAX_PUBLISH_BYTES) {
203
- return err(
204
- `Publish body too large: ${declaredLength} bytes exceeds ${MAX_PUBLISH_BYTES}`,
205
- 413,
206
- );
207
- }
208
-
209
- const buf = Buffer.from(await req.arrayBuffer());
210
- if (buf.length > MAX_PUBLISH_BYTES) {
211
- return err(`Publish body too large: ${buf.length} bytes exceeds ${MAX_PUBLISH_BYTES}`, 413);
212
- }
213
- if (buf.length < 8) return err('Request body too short');
214
-
215
- const metaLen = buf.readUInt32LE(0);
216
- // Cap the JSON metadata independently of the overall body. A malicious
217
- // payload could set metaLen close to MAX_PUBLISH_BYTES to force
218
- // multi-MB JSON.parse; the sparse protocol's metadata is always tiny
219
- // in practice (a few hundred bytes).
220
- if (metaLen > MAX_META_BYTES) {
221
- return err(`Metadata too large: ${metaLen} bytes exceeds ${MAX_META_BYTES}`, 413);
222
- }
223
- if (buf.length < 4 + metaLen + 4) return err('Request body truncated (metadata)');
224
-
225
- let meta: Record<string, unknown>;
226
- try {
227
- meta = JSON.parse(buf.subarray(4, 4 + metaLen).toString('utf-8')) as Record<string, unknown>;
228
- } catch {
229
- return err('Invalid JSON metadata');
230
- }
231
-
232
- const name = String(meta.name ?? '');
233
- const vers = String(meta.vers ?? '');
234
- // Description is optional, comes from the publishing client (the
235
- // celilo CLI reads manifest.yml#description and includes it). When
236
- // not provided we leave it undefined; the search endpoint falls
237
- // back to bootstrap data or empty.
238
- const description = typeof meta.description === 'string' ? meta.description : undefined;
239
-
240
- const validation = validateNameAndVersion(name, vers);
241
- if (!validation.ok) return err(validation.message);
242
-
243
- // Scope check AFTER the name is known: a package-scoped token may publish
244
- // only its own package; an admin token publishes anything (ISS-0140).
245
- if (!authorizePackage(req, name)) return unauthorized();
246
-
247
- const fileOffset = 4 + metaLen;
248
- const fileLen = buf.readUInt32LE(fileOffset);
249
- if (buf.length < fileOffset + 4 + fileLen) return err('Request body truncated (file)');
250
-
251
- const fileData = buf.subarray(fileOffset + 4, fileOffset + 4 + fileLen);
252
-
253
- if (storage.packageExists(name, vers)) {
254
- return err(`Version ${name}@${vers} already exists — versions are immutable`, 409);
255
- }
256
-
257
- const cksum = storage.storePackage(name, vers, fileData);
258
- storage.appendIndex({
259
- name,
260
- vers,
261
- deps: [],
262
- cksum: `sha256:${cksum}`,
263
- yanked: false,
264
- description,
265
- });
266
-
267
- console.log(`[registry] published ${name}@${vers} (${fileLen} bytes, sha256:${cksum})`);
268
- return Response.json({ ok: true, name, vers });
269
- }
270
-
271
- function setYanked(name: string, version: string, yanked: boolean): Response {
272
- const entries = storage.readIndex(name);
273
- const entry = entries.find((e) => e.vers === version);
274
- if (!entry) return notFound();
275
- entry.yanked = yanked;
276
- storage.updateIndex(name, entries);
277
- return Response.json({ ok: true });
278
- }
279
-
280
- /**
281
- * Mint a per-repo, package-scoped publish token (ISS-0140). Admin-only. The
282
- * `registry_publish` capability calls this on behalf of `source_forge.
283
- * registerRepo`. Reconciled: re-minting for the same repo rotates the token.
284
- * The raw token is returned ONCE — the caller sets it as the repo's Actions
285
- * secret; the server keeps only its hash.
286
- */
287
- async function handleMintToken(req: Request): Promise<Response> {
288
- if (!authorizeAdmin(req)) return unauthorized();
289
- let body: { repo?: unknown; scope?: unknown };
290
- try {
291
- body = (await req.json()) as { repo?: unknown; scope?: unknown };
292
- } catch {
293
- return err('Invalid JSON body');
294
- }
295
- const repo = typeof body.repo === 'string' ? body.repo.trim() : '';
296
- const scope = typeof body.scope === 'string' ? body.scope.trim() : '';
297
- if (!repo) return err('repo is required');
298
- if (!scope || !isValidName(scope)) return err('scope must be a valid package name');
299
-
300
- const { token, entry, revokedHashes } = scopedTokenStore.mint(repo, scope);
301
- for (const h of revokedHashes) auth.removeHashed(h);
302
- auth.addHashed(entry.hash, entry.scope);
303
- console.log(`[registry] minted scoped token for ${repo} → ${scope}`);
304
- return Response.json({ ok: true, token, scope, repo });
305
- }
306
-
307
- /** Revoke all scoped tokens for a repo (ISS-0140). Admin-only. */
308
- async function handleRevokeToken(req: Request): Promise<Response> {
309
- if (!authorizeAdmin(req)) return unauthorized();
310
- let body: { repo?: unknown };
311
- try {
312
- body = (await req.json()) as { repo?: unknown };
313
- } catch {
314
- return err('Invalid JSON body');
315
- }
316
- const repo = typeof body.repo === 'string' ? body.repo.trim() : '';
317
- if (!repo) return err('repo is required');
318
-
319
- const removed = scopedTokenStore.revoke(repo);
320
- for (const h of removed) auth.removeHashed(h);
321
- console.log(`[registry] revoked ${removed.length} scoped token(s) for ${repo}`);
322
- return Response.json({ ok: true, repo, revoked: removed.length });
323
- }
324
-
325
- const server = Bun.serve({
326
- port,
327
- async fetch(req, srv) {
328
- const url = new URL(req.url);
329
- const path = url.pathname;
330
- const method = req.method;
331
-
332
- // Strip the configured prefix (if any) before matching.
333
- if (pathPrefix && !path.startsWith(pathPrefix)) return notFound();
334
- const suffix = pathPrefix ? path.slice(pathPrefix.length) : path;
335
-
336
- // Polite redirect for the bare registry root: serve a 301 →
337
- // /modules/ on the celilo.computer site, with a minimal HTML
338
- // body for clients that don't follow redirects (curl without
339
- // -L, bots, bare HTTP libraries). Matches three forms so it
340
- // works whether PATH_PREFIX is set or empty:
341
- // - suffix='' → request was exactly the prefix, no slash
342
- // - suffix='/' → request was prefix + '/', or just '/'
343
- // - suffix='/index.html'
344
- // See apps/celilo/designs/REGISTRY_BROWSE_UI.md (decision D2).
345
- if (method === 'GET' && (suffix === '' || suffix === '/' || suffix === '/index.html')) {
346
- return landingResponse(publicUrl);
347
- }
348
-
349
- // Sparse config
350
- if (method === 'GET' && suffix === '/index/config.json') {
351
- return Response.json(sparseConfig);
352
- }
353
-
354
- // Sparse index files — the module name is the last path segment.
355
- // Reject anything that isn't a valid module name so the storage layer
356
- // can never see a path-traversal string via the index endpoint.
357
- if (method === 'GET' && suffix.startsWith('/index/')) {
358
- const name = decodeURIComponent(suffix.slice(suffix.lastIndexOf('/') + 1));
359
- if (!isValidName(name)) return notFound();
360
- const entries = resolveEntries(name);
361
- if (entries.length === 0) return notFound();
362
- const body = `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`;
363
- return new Response(body, {
364
- headers: { 'Content-Type': 'text/plain; charset=utf-8' },
365
- });
366
- }
367
-
368
- const apiBase = '/api/v1/modules';
369
-
370
- // Search
371
- if (method === 'GET' && suffix === apiBase) {
372
- const q = url.searchParams.get('q')?.toLowerCase() ?? '';
373
- const perPage = Math.min(Number(url.searchParams.get('per_page') ?? 25), 100);
374
- // Sort options: 'name' (default, alphabetical) or 'downloads'
375
- // (most-downloaded first, ties broken alphabetically). Anything
376
- // unrecognized falls back to 'name'.
377
- const sort = url.searchParams.get('sort') === 'downloads' ? 'downloads' : 'name';
378
-
379
- const publishedNames = new Set(storage.listModules().map((m) => m.name));
380
- const bootstrap = resolveBootstrap();
381
- const allNames = new Set([...publishedNames, ...bootstrap.keys()]);
382
- const filtered = [...allNames].filter((n) => !q || n.includes(q));
383
-
384
- // Build the per-module records first so sorting can read the
385
- // download counts. Empty input → empty output, no work done.
386
- const records = filtered.map((name) => {
387
- const entries = resolveEntries(name);
388
- const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1);
389
- // Description preference order:
390
- // 1. Latest published version's stored description (best —
391
- // reflects the captured-at-publish-time manifest).
392
- // 2. Bootstrap entry's manifest description (when serving
393
- // a module from a source directory the registry hasn't
394
- // formally received via publish).
395
- // 3. Empty string (older publishes that pre-date the
396
- // description capture and haven't been republished).
397
- const bootstrapEntry = bootstrap.get(name);
398
- const description = latest?.description ?? bootstrapEntry?.description ?? '';
399
- return {
400
- name,
401
- max_version: latest?.vers ?? '0.0.0',
402
- description,
403
- total_downloads: storage.getDownloads(name),
404
- };
405
- });
406
-
407
- if (sort === 'downloads') {
408
- records.sort((a, b) => {
409
- if (b.total_downloads !== a.total_downloads) {
410
- return b.total_downloads - a.total_downloads;
411
- }
412
- return a.name.localeCompare(b.name);
413
- });
414
- } else {
415
- records.sort((a, b) => a.name.localeCompare(b.name));
416
- }
417
-
418
- return Response.json({
419
- modules: records.slice(0, perPage),
420
- total: records.length,
421
- });
422
- }
423
-
424
- // Publish — rate-limit before even checking auth so a bad-token flood
425
- // can't crowd out legitimate traffic on the shared buckets. The scope
426
- // check happens inside handlePublish, once the package name is known.
427
- if (method === 'PUT' && suffix === `${apiBase}/new`) {
428
- const rl = rateLimitOrNull(req, srv);
429
- if (rl) return rl;
430
- return handlePublish(req);
431
- }
432
-
433
- // Mint / revoke per-repo scoped publish tokens (admin-only — ISS-0140).
434
- if (method === 'POST' && suffix === `${apiBase}/tokens/mint`) {
435
- const rl = rateLimitOrNull(req, srv);
436
- if (rl) return rl;
437
- return handleMintToken(req);
438
- }
439
- if (method === 'POST' && suffix === `${apiBase}/tokens/revoke`) {
440
- const rl = rateLimitOrNull(req, srv);
441
- if (rl) return rl;
442
- return handleRevokeToken(req);
443
- }
444
-
445
- if (suffix.startsWith(`${apiBase}/`)) {
446
- const modulePath = suffix.slice(apiBase.length + 1);
447
- // URL-decode path segments — module versions use a literal '+' to
448
- // separate semver from pkg revision (e.g. 1.0.0+1), and clients
449
- // URL-encode that as %2B. Without decoding, storage lookups miss
450
- // because they'd use the raw escape string.
451
- const parts = modulePath.split('/').map((p) => decodeURIComponent(p));
452
-
453
- // Module metadata
454
- if (method === 'GET' && parts.length === 1 && parts[0]) {
455
- const name = parts[0];
456
- if (!isValidName(name)) return notFound();
457
- const entries = resolveEntries(name);
458
- if (entries.length === 0) return notFound();
459
- // Description: latest non-yanked version's stored description,
460
- // falling back to the bootstrap entry's manifest description
461
- // for source-mode modules. Same precedence as the search
462
- // endpoint — see notes there.
463
- const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1);
464
- const bootstrapEntry = resolveBootstrap().get(name);
465
- const description = latest?.description ?? bootstrapEntry?.description ?? '';
466
- return Response.json({
467
- name,
468
- description,
469
- total_downloads: storage.getDownloads(name),
470
- versions: entries.map((v) => ({
471
- num: v.vers,
472
- yanked: v.yanked,
473
- created_at: new Date(0).toISOString(),
474
- })),
475
- });
476
- }
477
-
478
- // Download
479
- if (method === 'GET' && parts.length === 3 && parts[2] === 'download') {
480
- const [name, version] = parts;
481
- if (!isValidName(name) || !isValidVersion(version)) return notFound();
482
- const data = storage.readPackage(name, version);
483
- if (data) {
484
- // Bump the per-module counter on every successful download.
485
- // Liberal counting: no User-Agent filter, no de-duplication.
486
- // CI/crawler traffic inflates the number but the simplicity
487
- // is worth it — see REGISTRY_BROWSE_UI.md (Phase 3).
488
- storage.incrementDownloads(name);
489
- return new Response(new Uint8Array(data), {
490
- headers: {
491
- 'Content-Type': 'application/octet-stream',
492
- 'Content-Disposition': contentDispositionAttachment(`${name}-${version}.netapp`),
493
- },
494
- });
495
- }
496
- // Bootstrap fallback — published packages take precedence above.
497
- const b = resolveBootstrap().get(name);
498
- if (b) {
499
- storage.incrementDownloads(name);
500
- return serveBootstrapDownload(b);
501
- }
502
- return notFound();
503
- }
504
-
505
- // Yank
506
- if (method === 'DELETE' && parts.length === 3 && parts[2] === 'yank') {
507
- const rl = rateLimitOrNull(req, srv);
508
- if (rl) return rl;
509
- const [name, version] = parts;
510
- if (!isValidName(name) || !isValidVersion(version)) return notFound();
511
- if (!authorizePackage(req, name)) return unauthorized();
512
- return setYanked(name, version, true);
513
- }
514
-
515
- // Unyank
516
- if (method === 'PUT' && parts.length === 3 && parts[2] === 'unyank') {
517
- const rl = rateLimitOrNull(req, srv);
518
- if (rl) return rl;
519
- const [name, version] = parts;
520
- if (!isValidName(name) || !isValidVersion(version)) return notFound();
521
- if (!authorizePackage(req, name)) return unauthorized();
522
- return setYanked(name, version, false);
523
- }
524
- }
525
-
526
- return notFound();
527
- },
528
- });
529
-
530
- return server;
531
- }
532
-
533
- /** Env-var entry point used by bin/celilo-registry-server and the Dockerfile. */
534
- export function startFromEnv(): void {
535
- const dataDir = process.env.DATA_DIR ?? '/var/lib/celilo-registry';
536
- const port = Number(process.env.PORT ?? 3000);
537
- const pathPrefix = (process.env.PATH_PREFIX ?? '').replace(/\/$/, '');
538
- const publicUrl =
539
- process.env.PUBLIC_URL ?? `https://${process.env.DOMAIN ?? 'celilo.computer'}${pathPrefix}`;
540
- const bootstrapModulesDir = process.env.BOOTSTRAP_MODULES_DIR || undefined;
541
- const bootstrapUploadsDir = process.env.BOOTSTRAP_UPLOADS_DIR || undefined;
542
- const bootstrapCacheDir = process.env.BOOTSTRAP_CACHE_DIR || undefined;
543
- const auth = TokenAuth.fromEnv();
544
-
545
- const server = startServer({
546
- dataDir,
547
- port,
548
- pathPrefix,
549
- publicUrl,
550
- bootstrapModulesDir,
551
- bootstrapUploadsDir,
552
- bootstrapCacheDir,
553
- auth,
554
- });
555
-
556
- const bootstrapBits = [
557
- bootstrapModulesDir ? `modulesDir=${bootstrapModulesDir}` : '',
558
- bootstrapUploadsDir ? `uploadsDir=${bootstrapUploadsDir}` : '',
559
- ]
560
- .filter(Boolean)
561
- .join(', ');
562
- console.log(
563
- `[registry] listening on :${server.port} (prefix=${pathPrefix || '(none)'}, publicUrl=${publicUrl}${
564
- bootstrapBits ? `, bootstrap=[${bootstrapBits}]` : ''
565
- }, auth=${auth.hasTokens() ? 'enabled' : 'disabled (publish will always 401)'})`,
566
- );
567
- }
568
-
569
- // Invoke when run as an entry point (`bun run src/server.ts`).
570
- if (import.meta.main) {
571
- startFromEnv();
572
- }