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