@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.
Files changed (52) hide show
  1. package/config/cpanel-host/cpanel-host-ca.crt +20 -0
  2. package/config/cpanel-host/docroot/.htaccess +14 -0
  3. package/config/cpanel-host/docroot/cms.html +4 -0
  4. package/config/cpanel-host/site-tls.crt +20 -0
  5. package/config/cpanel-host/site-tls.key +28 -0
  6. package/config/cpanel-host/site.conf +27 -0
  7. package/config/dns/com.zone +7 -0
  8. package/config/dns/knot-namecheap.conf +6 -0
  9. package/config/dns/tangohost.com.zone +14 -0
  10. package/config/resolver/unbound.conf +26 -0
  11. package/config/routing/management-routes.sh +9 -4
  12. package/config/routing/resolver-internal-routes.sh +10 -0
  13. package/docker/Dockerfile.cpanel-host-sim +70 -0
  14. package/docker/Dockerfile.firewall +12 -0
  15. package/docker/Dockerfile.management +6 -0
  16. package/docker/Dockerfile.observer +11 -0
  17. package/docker/Dockerfile.signal-cli +60 -0
  18. package/docker/Dockerfile.signal-release +36 -0
  19. package/docker/Dockerfile.signal-sim +17 -0
  20. package/docker/Dockerfile.target-machine +9 -0
  21. package/package.json +3 -3
  22. package/registry-server/package.json +17 -0
  23. package/registry-server/src/auth.test.ts +76 -0
  24. package/registry-server/src/auth.ts +105 -0
  25. package/registry-server/src/bootstrap-packaging.test.ts +71 -0
  26. package/registry-server/src/bootstrap.ts +246 -0
  27. package/registry-server/src/index.ts +16 -0
  28. package/registry-server/src/introspection.test.ts +247 -0
  29. package/registry-server/src/introspection.ts +204 -0
  30. package/registry-server/src/landing.ts +90 -0
  31. package/registry-server/src/module-owner-store.test.ts +85 -0
  32. package/registry-server/src/module-owner-store.ts +108 -0
  33. package/registry-server/src/rate-limit.test.ts +62 -0
  34. package/registry-server/src/rate-limit.ts +83 -0
  35. package/registry-server/src/scoped-token-store.test.ts +93 -0
  36. package/registry-server/src/scoped-token-store.ts +110 -0
  37. package/registry-server/src/server.test.ts +771 -0
  38. package/registry-server/src/server.ts +701 -0
  39. package/registry-server/src/storage.test.ts +148 -0
  40. package/registry-server/src/storage.ts +150 -0
  41. package/registry-server/src/validation.test.ts +86 -0
  42. package/registry-server/src/validation.ts +60 -0
  43. package/registry-server/tsconfig.json +15 -0
  44. package/scripts/stage-libsignal.ts +116 -0
  45. package/simulators/signal-cli/server.ts +272 -0
  46. package/src/cli/build.ts +7 -0
  47. package/src/cli/index.ts +6 -0
  48. package/src/container-manager.ts +5 -1
  49. package/src/docker-compose-generator.ts +108 -4
  50. package/src/network-builder.ts +52 -0
  51. package/src/simulator-ips.ts +9 -0
  52. package/src/types.ts +34 -0
@@ -0,0 +1,204 @@
1
+ import { ADMIN_SCOPE, bearerToken } from './auth';
2
+
3
+ /**
4
+ * RFC 7662 token-introspection verify-bridge
5
+ * (SECURE_MODULE_PUBLISH.md §5[D-A], MODULE_PACKAGE_SIGNING.md Phase 1).
6
+ *
7
+ * The registry POSTs a presented publish token to the idp's introspection
8
+ * endpoint, authenticating with its own confidential OIDC client credentials
9
+ * (provisioned on install — ce-7aa). It reads back `{active, sub, groups, exp}`
10
+ * and maps a verified identity to a publish scope the request path already
11
+ * understands (ADMIN_SCOPE / per-package — see {@link claimToScope}).
12
+ *
13
+ * Two properties this module MUST preserve:
14
+ * 1. **Fail closed.** Any non-2xx response, network error, or malformed body
15
+ * denies the token (returns null). The idp being unreachable never
16
+ * degrades into "allow".
17
+ * 2. **No secrets in logs.** The token and client secret are never logged;
18
+ * only HTTP status / error class is.
19
+ */
20
+
21
+ /** Validated subset of an RFC 7662 introspection response. */
22
+ export interface IntrospectionResult {
23
+ active: boolean;
24
+ sub?: string;
25
+ groups?: string[];
26
+ exp?: number;
27
+ }
28
+
29
+ export interface IntrospectionConfig {
30
+ /** RFC 7662 introspection endpoint (OIDC_INTROSPECTION_ENDPOINT). */
31
+ endpoint: string;
32
+ /** Confidential OIDC client id (OIDC_CLIENT_ID). */
33
+ clientId: string;
34
+ /** Confidential OIDC client secret (OIDC_CLIENT_SECRET). */
35
+ clientSecret: string;
36
+ /** Group whose members map to ADMIN_SCOPE (REGISTRY_ADMIN_GROUP). */
37
+ adminGroup: string;
38
+ /**
39
+ * Group whose members may publish modules they own (REGISTRY_PUBLISHER_GROUP,
40
+ * ce-1ch). Configurable, not a literal. Members claim/publish per the
41
+ * module-owner table; admins additionally publish/reassign anything.
42
+ */
43
+ publisherGroup: string;
44
+ }
45
+
46
+ /**
47
+ * A verified idp identity (ce-1ch). `identify()` resolves an Authorization
48
+ * header to this via introspection, or null when the token is inactive/expired/
49
+ * unverifiable or carries no `sub` (ownership requires a stable subject).
50
+ */
51
+ export interface VerifiedIdentity {
52
+ /**
53
+ * Verified subject — the owner key in the module-owner table. Optional: an
54
+ * admin identity may publish without one (admins need no ownership anchor),
55
+ * but the publisher/owner path requires it to attribute a claim.
56
+ */
57
+ sub?: string;
58
+ /** Member of the admin group → may publish/reassign any module. */
59
+ isAdmin: boolean;
60
+ /** Member of the publisher group → may claim + publish owned modules. */
61
+ isPublisher: boolean;
62
+ /**
63
+ * The group that grants this identity's publish rights (admin group when
64
+ * admin, else publisher group when publisher, else undefined). Recorded as
65
+ * the owner entry's `sourceGroup` at claim time.
66
+ */
67
+ group?: string;
68
+ }
69
+
70
+ /** Default admin group name when REGISTRY_ADMIN_GROUP is unset. */
71
+ const DEFAULT_ADMIN_GROUP = 'celilo-admins';
72
+
73
+ /** Default publisher group name when REGISTRY_PUBLISHER_GROUP is unset. */
74
+ const DEFAULT_PUBLISHER_GROUP = 'celilo-authors';
75
+
76
+ /**
77
+ * Read introspection config from the environment (delivered by the module's
78
+ * OIDC drop-in EnvironmentFile — ce-7aa). Returns null when introspection is
79
+ * not configured, in which case the server keeps the opaque-token-only path.
80
+ */
81
+ export function introspectionConfigFromEnv(): IntrospectionConfig | null {
82
+ const endpoint = process.env.OIDC_INTROSPECTION_ENDPOINT?.trim();
83
+ const clientId = process.env.OIDC_CLIENT_ID?.trim();
84
+ const clientSecret = process.env.OIDC_CLIENT_SECRET?.trim();
85
+ if (!endpoint || !clientId || !clientSecret) return null;
86
+ return {
87
+ endpoint,
88
+ clientId,
89
+ clientSecret,
90
+ adminGroup: process.env.REGISTRY_ADMIN_GROUP?.trim() || DEFAULT_ADMIN_GROUP,
91
+ publisherGroup: process.env.REGISTRY_PUBLISHER_GROUP?.trim() || DEFAULT_PUBLISHER_GROUP,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Map a verified identity's claims to a publish scope the request path
97
+ * consumes. Today: membership in the admin group → ADMIN_SCOPE (`*`).
98
+ *
99
+ * ponytail: per-module ownership (a verified non-admin identity may publish
100
+ * only modules it owns) is the module-owner table — ce-1ch — which extends
101
+ * this seam. Until then a verified non-admin identity is denied (null).
102
+ */
103
+ export function claimToScope(claims: IntrospectionResult, adminGroup: string): string | null {
104
+ if (claims.groups?.includes(adminGroup)) return ADMIN_SCOPE;
105
+ return null;
106
+ }
107
+
108
+ /** Hand-validate an untrusted introspection JSON body (no zod dep in this package). */
109
+ function parseIntrospection(json: unknown): IntrospectionResult | null {
110
+ if (typeof json !== 'object' || json === null) return null;
111
+ const o = json as Record<string, unknown>;
112
+ if (typeof o.active !== 'boolean') return null;
113
+ const groups = Array.isArray(o.groups)
114
+ ? o.groups.filter((g): g is string => typeof g === 'string')
115
+ : undefined;
116
+ return {
117
+ active: o.active,
118
+ sub: typeof o.sub === 'string' ? o.sub : undefined,
119
+ groups,
120
+ exp: typeof o.exp === 'number' ? o.exp : undefined,
121
+ };
122
+ }
123
+
124
+ function errClass(error: unknown): string {
125
+ return error instanceof Error ? error.name : 'unknown';
126
+ }
127
+
128
+ export class IntrospectionVerifier {
129
+ constructor(private readonly config: IntrospectionConfig) {}
130
+
131
+ /** Construct from env, or undefined when introspection isn't configured. */
132
+ static fromEnv(): IntrospectionVerifier | undefined {
133
+ const config = introspectionConfigFromEnv();
134
+ return config ? new IntrospectionVerifier(config) : undefined;
135
+ }
136
+
137
+ /**
138
+ * Verify an Authorization header value via introspection and return the
139
+ * publish scope of the identity (ADMIN_SCOPE / per-package), or null when
140
+ * the token is inactive, expired, unverifiable, or maps to no scope.
141
+ * Fails CLOSED on every error.
142
+ */
143
+ async scopeOf(header: string): Promise<string | null> {
144
+ const token = bearerToken(header);
145
+ if (!token) return null;
146
+ const claims = await this.introspect(token);
147
+ if (!claims || !claims.active) return null;
148
+ // RFC 7662 `active:false` already covers expiry, but double-check `exp`
149
+ // defensively (seconds since epoch) so a misbehaving idp can't slip an
150
+ // expired-but-active token past us.
151
+ if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) return null;
152
+ return claimToScope(claims, this.config.adminGroup);
153
+ }
154
+
155
+ /**
156
+ * Verify an Authorization header and resolve the caller's identity for the
157
+ * hybrid group + owner-table authorization (ce-1ch). Returns null on the same
158
+ * fail-closed conditions as {@link scopeOf}. `sub` may be absent (an admin can
159
+ * publish without one); the owner path in the server requires it and denies
160
+ * a publisher identity that lacks it.
161
+ */
162
+ async identify(header: string): Promise<VerifiedIdentity | null> {
163
+ const token = bearerToken(header);
164
+ if (!token) return null;
165
+ const claims = await this.introspect(token);
166
+ if (!claims || !claims.active) return null;
167
+ if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) return null;
168
+ const isAdmin = claims.groups?.includes(this.config.adminGroup) ?? false;
169
+ const isPublisher = claims.groups?.includes(this.config.publisherGroup) ?? false;
170
+ const group = isAdmin
171
+ ? this.config.adminGroup
172
+ : isPublisher
173
+ ? this.config.publisherGroup
174
+ : undefined;
175
+ return { sub: claims.sub, isAdmin, isPublisher, group };
176
+ }
177
+
178
+ private async introspect(token: string): Promise<IntrospectionResult | null> {
179
+ try {
180
+ const basic = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString(
181
+ 'base64',
182
+ );
183
+ const resp = await fetch(this.config.endpoint, {
184
+ method: 'POST',
185
+ headers: {
186
+ 'Content-Type': 'application/x-www-form-urlencoded',
187
+ Authorization: `Basic ${basic}`,
188
+ Accept: 'application/json',
189
+ },
190
+ body: new URLSearchParams({ token, token_type_hint: 'access_token' }).toString(),
191
+ });
192
+ if (!resp.ok) {
193
+ console.error(`[registry] introspection HTTP ${resp.status} — denying (fail closed)`);
194
+ return null;
195
+ }
196
+ return parseIntrospection(await resp.json());
197
+ } catch (error) {
198
+ console.error(
199
+ `[registry] introspection request failed (${errClass(error)}) — denying (fail closed)`,
200
+ );
201
+ return null;
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Landing-page response for `GET /` (and `/index.html`) on the registry.
3
+ *
4
+ * This is the polite-301 from the design doc:
5
+ * (apps/celilo/designs/REGISTRY_BROWSE_UI.md, decision D2). The response
6
+ * carries `301 Moved Permanently` with `Location:` pointing at the
7
+ * celilo.computer site's `/modules/` browse UI — interactive browsers
8
+ * follow the redirect. Clients that don't follow by default (curl
9
+ * without `-L`, naive bots, bare-bones HTTP libraries) see the body,
10
+ * which carries the install one-liner, a link to the browse UI, and
11
+ * the API endpoints they probably actually wanted.
12
+ *
13
+ * Single endpoint, no Astro dep, no client JS, no asset pipeline.
14
+ */
15
+
16
+ /**
17
+ * HTML-escape `<`, `>`, `&`, `"` so values interpolated into the body
18
+ * can't inject markup. Operator-controlled values (publicUrl) are
19
+ * trusted in practice, but defensive escaping costs nothing.
20
+ */
21
+ function escapeHtml(s: string): string {
22
+ return s
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;');
27
+ }
28
+
29
+ /**
30
+ * Build the polite-301 response.
31
+ *
32
+ * @param publicUrl The registry's public URL (typically
33
+ * `https://celilo.computer/registry`). The site's
34
+ * origin is derived by stripping the path. The
35
+ * `/modules/` browse URL on that origin is the
36
+ * redirect target.
37
+ */
38
+ export function landingResponse(publicUrl: string): Response {
39
+ const u = new URL(publicUrl);
40
+ const siteOrigin = u.origin;
41
+ const browseUrl = `${siteOrigin}/modules/`;
42
+ const installUrl = `${siteOrigin}/install.sh`;
43
+ const apiBase = publicUrl.replace(/\/$/, '');
44
+
45
+ const body = `<!DOCTYPE html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="UTF-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1">
50
+ <title>Celilo Module Registry</title>
51
+ <style>
52
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
53
+ max-width: 640px; margin: 4em auto; padding: 0 1em;
54
+ line-height: 1.6; color: #222; }
55
+ h1 { margin-bottom: 0.2em; }
56
+ .lead { color: #666; margin-top: 0; }
57
+ pre { background: #f5f5f5; padding: 0.8em 1em; border-radius: 6px;
58
+ overflow-x: auto; font-size: 0.9em; }
59
+ a { color: #0d72b9; }
60
+ small { color: #888; }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <h1>Celilo Module Registry</h1>
65
+ <p class="lead">Home-lab orchestration modules — published, versioned, install via the Celilo CLI.</p>
66
+
67
+ <p>If you're a person, head to <a href="${escapeHtml(browseUrl)}">browse the modules →</a></p>
68
+
69
+ <p>Install the Celilo CLI:</p>
70
+ <pre><code>curl -fsSL ${escapeHtml(installUrl)} | bash</code></pre>
71
+
72
+ <p>If you're a tool, the JSON API is here:</p>
73
+ <pre><code>GET ${escapeHtml(apiBase)}/api/v1/modules
74
+ GET ${escapeHtml(apiBase)}/api/v1/modules/{name}
75
+ GET ${escapeHtml(apiBase)}/api/v1/modules/{name}/{version}/download</code></pre>
76
+
77
+ <p><small>This URL is a 301 to <a href="${escapeHtml(browseUrl)}">${escapeHtml(browseUrl)}</a>. Most browsers followed it before you saw this.</small></p>
78
+ </body>
79
+ </html>
80
+ `;
81
+
82
+ return new Response(body, {
83
+ status: 301,
84
+ headers: {
85
+ Location: browseUrl,
86
+ 'Content-Type': 'text/html; charset=utf-8',
87
+ 'Cache-Control': 'public, max-age=300',
88
+ },
89
+ });
90
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ type ModuleOwnerEntry,
4
+ type ModuleOwnerPersistence,
5
+ ModuleOwnerStore,
6
+ } from './module-owner-store';
7
+
8
+ /** In-memory persistence seam so the store is testable without the filesystem. */
9
+ function memPersistence(initial: ModuleOwnerEntry[] = []): ModuleOwnerPersistence & {
10
+ saved: ModuleOwnerEntry[];
11
+ } {
12
+ const state = { saved: [...initial] };
13
+ return {
14
+ saved: state.saved,
15
+ load: () => [...state.saved],
16
+ save(entries) {
17
+ state.saved.length = 0;
18
+ state.saved.push(...entries);
19
+ },
20
+ };
21
+ }
22
+
23
+ const fixedNow = () => '2026-01-01T00:00:00.000Z';
24
+
25
+ describe('ModuleOwnerStore', () => {
26
+ test('claim records ownership of an unclaimed name', () => {
27
+ const p = memPersistence();
28
+ const store = new ModuleOwnerStore(p, fixedNow);
29
+ const entry = store.claim('homebridge', 'alice', 'celilo-authors');
30
+ expect(entry).toEqual({
31
+ moduleName: 'homebridge',
32
+ ownerSub: 'alice',
33
+ claimedAt: fixedNow(),
34
+ sourceGroup: 'celilo-authors',
35
+ });
36
+ expect(store.get('homebridge')?.ownerSub).toBe('alice');
37
+ expect(p.saved).toHaveLength(1);
38
+ });
39
+
40
+ test('claim never steals an already-owned name (returns existing)', () => {
41
+ const store = new ModuleOwnerStore(memPersistence(), fixedNow);
42
+ store.claim('homebridge', 'alice', 'celilo-authors');
43
+ const second = store.claim('homebridge', 'bob', 'celilo-authors');
44
+ expect(second.ownerSub).toBe('alice');
45
+ expect(store.get('homebridge')?.ownerSub).toBe('alice');
46
+ });
47
+
48
+ test('reassign overwrites an existing owner (admin path)', () => {
49
+ const store = new ModuleOwnerStore(memPersistence(), fixedNow);
50
+ store.claim('homebridge', 'alice', 'celilo-authors');
51
+ const reassigned = store.reassign('homebridge', 'bob', 'admin-reassign');
52
+ expect(reassigned.ownerSub).toBe('bob');
53
+ expect(store.get('homebridge')?.ownerSub).toBe('bob');
54
+ // No duplicate rows for the same name.
55
+ expect(store.list().filter((e) => e.moduleName === 'homebridge')).toHaveLength(1);
56
+ });
57
+
58
+ test('reassign creates a record when the name was unclaimed', () => {
59
+ const store = new ModuleOwnerStore(memPersistence(), fixedNow);
60
+ store.reassign('caddy', 'carol', 'admin-reassign');
61
+ expect(store.get('caddy')?.ownerSub).toBe('carol');
62
+ });
63
+
64
+ test('get returns undefined for an unclaimed name', () => {
65
+ const store = new ModuleOwnerStore(memPersistence(), fixedNow);
66
+ expect(store.get('nope')).toBeUndefined();
67
+ });
68
+
69
+ test('loads persisted entries on construction', () => {
70
+ const p = memPersistence([
71
+ { moduleName: 'caddy', ownerSub: 'dan', claimedAt: fixedNow(), sourceGroup: 'celilo-admins' },
72
+ ]);
73
+ const store = new ModuleOwnerStore(p, fixedNow);
74
+ expect(store.get('caddy')?.ownerSub).toBe('dan');
75
+ expect(store.list()).toHaveLength(1);
76
+ });
77
+
78
+ test('list returns a copy (mutating it does not affect the store)', () => {
79
+ const store = new ModuleOwnerStore(memPersistence(), fixedNow);
80
+ store.claim('a', 'x', 'g');
81
+ const list = store.list();
82
+ list.pop();
83
+ expect(store.list()).toHaveLength(1);
84
+ });
85
+ });
@@ -0,0 +1,108 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ /**
5
+ * Persisted module-owner table (SECURE_MODULE_PUBLISH.md §5[D-C], ce-1ch).
6
+ *
7
+ * The introspection verify-bridge (ce-s7e) proves *who* a publish token belongs
8
+ * to (a verified `sub` + group claims). This table gates *which modules* that
9
+ * identity may publish: a module name is **first-publish-claims** — the first
10
+ * verified publisher of an unclaimed name records ownership; thereafter only the
11
+ * owner (or an admin) may publish it. This defends the confused-deputy case:
12
+ * Author-A holding a valid idp token cannot publish Author-B's module.
13
+ *
14
+ * Ownership is low-friction / npm-style: no pre-registration, admin can reassign.
15
+ * Mirrors {@link ScopedTokenStore} (JSON file + I/O seam for unit testing).
16
+ */
17
+
18
+ export interface ModuleOwnerEntry {
19
+ /** Module name this ownership record governs. */
20
+ moduleName: string;
21
+ /** Verified idp subject that owns the name (from introspection `sub`). */
22
+ ownerSub: string;
23
+ /** ISO timestamp the name was first claimed. */
24
+ claimedAt: string;
25
+ /** Group claim that granted the claim (admin or publisher group at claim time). */
26
+ sourceGroup: string;
27
+ }
28
+
29
+ /** I/O seam (Rule 2.3) so the store is unit-testable without the filesystem. */
30
+ export interface ModuleOwnerPersistence {
31
+ load(): ModuleOwnerEntry[];
32
+ save(entries: ModuleOwnerEntry[]): void;
33
+ }
34
+
35
+ /** File-backed persistence: a JSON array at `filePath`, tolerant of a missing file. */
36
+ export function fileModuleOwnerPersistence(filePath: string): ModuleOwnerPersistence {
37
+ return {
38
+ load() {
39
+ if (!existsSync(filePath)) return [];
40
+ try {
41
+ const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
42
+ return Array.isArray(parsed) ? (parsed as ModuleOwnerEntry[]) : [];
43
+ } catch {
44
+ return [];
45
+ }
46
+ },
47
+ save(entries) {
48
+ mkdirSync(dirname(filePath), { recursive: true });
49
+ writeFileSync(filePath, JSON.stringify(entries, null, 2));
50
+ },
51
+ };
52
+ }
53
+
54
+ export class ModuleOwnerStore {
55
+ private entries: ModuleOwnerEntry[];
56
+
57
+ constructor(
58
+ private readonly persistence: ModuleOwnerPersistence,
59
+ private readonly now: () => string = () => new Date().toISOString(),
60
+ ) {
61
+ this.entries = persistence.load();
62
+ }
63
+
64
+ list(): ModuleOwnerEntry[] {
65
+ return [...this.entries];
66
+ }
67
+
68
+ /** The ownership record for `moduleName`, or undefined when unclaimed. */
69
+ get(moduleName: string): ModuleOwnerEntry | undefined {
70
+ return this.entries.find((e) => e.moduleName === moduleName);
71
+ }
72
+
73
+ /**
74
+ * Record ownership of an **unclaimed** name for `ownerSub`. If the name is
75
+ * already claimed this is a no-op and returns the *existing* entry — claiming
76
+ * never steals a name (use {@link reassign} for admin overwrite). Returns the
77
+ * effective owner entry either way.
78
+ */
79
+ claim(moduleName: string, ownerSub: string, sourceGroup: string): ModuleOwnerEntry {
80
+ const existing = this.get(moduleName);
81
+ if (existing) return existing;
82
+ const entry: ModuleOwnerEntry = {
83
+ moduleName,
84
+ ownerSub,
85
+ claimedAt: this.now(),
86
+ sourceGroup,
87
+ };
88
+ this.entries = [...this.entries, entry];
89
+ this.persistence.save(this.entries);
90
+ return entry;
91
+ }
92
+
93
+ /**
94
+ * Overwrite (or create) the ownership record for `moduleName` — admin
95
+ * reassignment. Unlike {@link claim} this steals an already-owned name.
96
+ */
97
+ reassign(moduleName: string, ownerSub: string, sourceGroup: string): ModuleOwnerEntry {
98
+ const entry: ModuleOwnerEntry = {
99
+ moduleName,
100
+ ownerSub,
101
+ claimedAt: this.now(),
102
+ sourceGroup,
103
+ };
104
+ this.entries = [...this.entries.filter((e) => e.moduleName !== moduleName), entry];
105
+ this.persistence.save(this.entries);
106
+ return entry;
107
+ }
108
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { clientIp, createRateLimiter } from './rate-limit';
3
+
4
+ describe('createRateLimiter', () => {
5
+ test('allows up to max, then rejects', () => {
6
+ const limiter = createRateLimiter({ max: 3, windowMs: 60_000, now: () => 0 });
7
+ expect(limiter.take('1.1.1.1').ok).toBe(true);
8
+ expect(limiter.take('1.1.1.1').ok).toBe(true);
9
+ expect(limiter.take('1.1.1.1').ok).toBe(true);
10
+ const over = limiter.take('1.1.1.1');
11
+ expect(over.ok).toBe(false);
12
+ if (!over.ok) expect(over.retryAfterSec).toBeGreaterThan(0);
13
+ });
14
+
15
+ test('distinct IPs are tracked separately', () => {
16
+ const limiter = createRateLimiter({ max: 1, windowMs: 60_000, now: () => 0 });
17
+ expect(limiter.take('1.1.1.1').ok).toBe(true);
18
+ expect(limiter.take('2.2.2.2').ok).toBe(true); // different IP, fresh allowance
19
+ expect(limiter.take('1.1.1.1').ok).toBe(false);
20
+ });
21
+
22
+ test('bucket resets after window elapses', () => {
23
+ let t = 0;
24
+ const limiter = createRateLimiter({ max: 1, windowMs: 1000, now: () => t });
25
+ expect(limiter.take('1.1.1.1').ok).toBe(true);
26
+ expect(limiter.take('1.1.1.1').ok).toBe(false);
27
+ t = 1001;
28
+ expect(limiter.take('1.1.1.1').ok).toBe(true);
29
+ });
30
+
31
+ test('retryAfterSec reflects remaining window', () => {
32
+ const limiter = createRateLimiter({ max: 1, windowMs: 60_000, now: () => 0 });
33
+ limiter.take('1.1.1.1');
34
+ const r = limiter.take('1.1.1.1');
35
+ expect(r.ok).toBe(false);
36
+ if (!r.ok) {
37
+ expect(r.retryAfterSec).toBeLessThanOrEqual(60);
38
+ expect(r.retryAfterSec).toBeGreaterThan(0);
39
+ }
40
+ });
41
+ });
42
+
43
+ describe('clientIp', () => {
44
+ function req(headers: Record<string, string>): Request {
45
+ return new Request('http://x/', { headers });
46
+ }
47
+ const stubServer = {
48
+ requestIP: () => ({ address: '10.0.0.1' }),
49
+ };
50
+
51
+ test('uses X-Forwarded-For first entry when present', () => {
52
+ expect(clientIp(req({ 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }), stubServer)).toBe('1.1.1.1');
53
+ });
54
+
55
+ test('falls back to Bun.Server.requestIP when XFF absent', () => {
56
+ expect(clientIp(req({}), stubServer)).toBe('10.0.0.1');
57
+ });
58
+
59
+ test('returns "unknown" when neither available', () => {
60
+ expect(clientIp(req({}), { requestIP: () => null })).toBe('unknown');
61
+ });
62
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Simple in-memory per-IP rate limiter for the auth-gated endpoints
3
+ * (publish, yank, unyank). The public-read endpoints (download, index,
4
+ * search) are left unlimited — they're cacheable and the body-size cap
5
+ * on publish already covers the main DoS vector.
6
+ *
7
+ * We implement this in-process rather than in Caddy because the stock
8
+ * apt-installed Caddy doesn't include the community `caddy-ratelimit`
9
+ * plugin. If we ever switch the caddy module to `xcaddy build` with the
10
+ * plugin, we can move this upstream and delete this file.
11
+ *
12
+ * The limiter is a fixed window counter: each IP gets a fresh allowance
13
+ * every `windowMs`. No burst smoothing, which is fine for the tiny
14
+ * traffic a registry actually sees — we just want to keep a bad actor
15
+ * from flooding the disk via repeated publish attempts.
16
+ */
17
+
18
+ interface Bucket {
19
+ count: number;
20
+ /** Epoch ms when this bucket resets to 0 and `windowMs` restarts. */
21
+ resetAt: number;
22
+ }
23
+
24
+ export interface RateLimiter {
25
+ /**
26
+ * Record one hit from `ip`. Returns {ok: true, remaining} if under the
27
+ * limit; {ok: false, retryAfterSec} if the window is exhausted.
28
+ */
29
+ take(ip: string): { ok: true; remaining: number } | { ok: false; retryAfterSec: number };
30
+ }
31
+
32
+ export function createRateLimiter(opts: {
33
+ /** Max events per IP per window. */
34
+ max: number;
35
+ /** Window length in ms. */
36
+ windowMs: number;
37
+ /** Inject a clock for tests; defaults to Date.now. */
38
+ now?: () => number;
39
+ }): RateLimiter {
40
+ const buckets = new Map<string, Bucket>();
41
+ const now = opts.now ?? (() => Date.now());
42
+
43
+ // Opportunistic cleanup: if the map grows past a size threshold, drop
44
+ // expired entries. Keeps memory bounded even under churny attackers
45
+ // hitting from rotating IPs.
46
+ const CLEANUP_AT = 1000;
47
+
48
+ return {
49
+ take(ip: string) {
50
+ const t = now();
51
+ let bucket = buckets.get(ip);
52
+ if (!bucket || t >= bucket.resetAt) {
53
+ bucket = { count: 0, resetAt: t + opts.windowMs };
54
+ buckets.set(ip, bucket);
55
+ }
56
+ bucket.count += 1;
57
+ if (bucket.count > opts.max) {
58
+ return { ok: false, retryAfterSec: Math.max(1, Math.ceil((bucket.resetAt - t) / 1000)) };
59
+ }
60
+ if (buckets.size > CLEANUP_AT) {
61
+ for (const [k, v] of buckets) if (t >= v.resetAt) buckets.delete(k);
62
+ }
63
+ return { ok: true, remaining: opts.max - bucket.count };
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Extract the caller's IP for rate-limit keying. Prefers the first entry
70
+ * of X-Forwarded-For (trusted-proxy assumption — we sit behind Caddy);
71
+ * falls back to the raw socket peer via Bun.Server.requestIP.
72
+ */
73
+ export function clientIp(
74
+ req: Request,
75
+ server: { requestIP(r: Request): { address: string } | null },
76
+ ): string {
77
+ const xff = req.headers.get('x-forwarded-for');
78
+ if (xff) {
79
+ const first = xff.split(',')[0]?.trim();
80
+ if (first) return first;
81
+ }
82
+ return server.requestIP(req)?.address ?? 'unknown';
83
+ }