@celilo/e2e 0.9.3 → 0.10.2

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 (62) 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/fw-ext-routes.sh +16 -1
  12. package/config/routing/management-routes.sh +14 -4
  13. package/config/routing/minio-startup.sh +9 -0
  14. package/config/routing/public-sim-entrypoint.sh +24 -0
  15. package/config/routing/resolver-internal-routes.sh +10 -0
  16. package/docker/Dockerfile.apt-repo-sim +8 -0
  17. package/docker/Dockerfile.cpanel-host-sim +75 -0
  18. package/docker/Dockerfile.firewall +12 -0
  19. package/docker/Dockerfile.management +6 -0
  20. package/docker/Dockerfile.minio +7 -0
  21. package/docker/Dockerfile.npm-registry-sim +8 -0
  22. package/docker/Dockerfile.observer +11 -0
  23. package/docker/Dockerfile.registry +8 -0
  24. package/docker/Dockerfile.signal-cli +60 -0
  25. package/docker/Dockerfile.signal-release +44 -0
  26. package/docker/Dockerfile.signal-sim +17 -0
  27. package/docker/Dockerfile.target-machine +9 -0
  28. package/package.json +3 -3
  29. package/registry-server/package.json +17 -0
  30. package/registry-server/src/auth.test.ts +76 -0
  31. package/registry-server/src/auth.ts +105 -0
  32. package/registry-server/src/bootstrap-packaging.test.ts +71 -0
  33. package/registry-server/src/bootstrap.ts +250 -0
  34. package/registry-server/src/index.ts +16 -0
  35. package/registry-server/src/introspection.test.ts +247 -0
  36. package/registry-server/src/introspection.ts +204 -0
  37. package/registry-server/src/landing.ts +90 -0
  38. package/registry-server/src/module-owner-store.test.ts +85 -0
  39. package/registry-server/src/module-owner-store.ts +108 -0
  40. package/registry-server/src/rate-limit.test.ts +62 -0
  41. package/registry-server/src/rate-limit.ts +83 -0
  42. package/registry-server/src/scoped-token-store.test.ts +93 -0
  43. package/registry-server/src/scoped-token-store.ts +110 -0
  44. package/registry-server/src/server.test.ts +771 -0
  45. package/registry-server/src/server.ts +701 -0
  46. package/registry-server/src/storage.test.ts +148 -0
  47. package/registry-server/src/storage.ts +150 -0
  48. package/registry-server/src/validation.test.ts +86 -0
  49. package/registry-server/src/validation.ts +60 -0
  50. package/registry-server/tsconfig.json +15 -0
  51. package/scripts/stage-libsignal.ts +116 -0
  52. package/simulators/signal-cli/server.ts +328 -0
  53. package/src/cli/build.ts +7 -0
  54. package/src/cli/index.ts +6 -0
  55. package/src/container-manager.ts +47 -10
  56. package/src/docker-compose-generator.ts +120 -16
  57. package/src/exit-cleanup.test.ts +56 -0
  58. package/src/index.ts +7 -0
  59. package/src/network-builder.ts +66 -0
  60. package/src/public-sim-routes.test.ts +117 -0
  61. package/src/simulator-ips.ts +9 -0
  62. package/src/types.ts +71 -1
@@ -0,0 +1,247 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { ADMIN_SCOPE } from './auth';
3
+ import {
4
+ IntrospectionVerifier,
5
+ claimToScope,
6
+ introspectionConfigFromEnv,
7
+ } from './introspection';
8
+
9
+ const CONFIG = {
10
+ endpoint: 'https://idp.test/application/o/introspect/',
11
+ clientId: 'celilo-registry',
12
+ clientSecret: 'super-secret',
13
+ adminGroup: 'celilo-admins',
14
+ publisherGroup: 'celilo-authors',
15
+ };
16
+
17
+ /** Replace global fetch with a stub returning `body` at `status`; restore after. */
18
+ function stubFetch(handler: (url: string, init: RequestInit) => Response): void {
19
+ // @ts-expect-error - overriding the global for the duration of a test
20
+ globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) =>
21
+ handler(String(url), init ?? {});
22
+ }
23
+
24
+ const realFetch = globalThis.fetch;
25
+ afterEach(() => {
26
+ globalThis.fetch = realFetch;
27
+ });
28
+
29
+ function introspectResponse(payload: unknown, status = 200): Response {
30
+ return new Response(JSON.stringify(payload), {
31
+ status,
32
+ headers: { 'Content-Type': 'application/json' },
33
+ });
34
+ }
35
+
36
+ describe('claimToScope', () => {
37
+ test('admin-group member → ADMIN_SCOPE', () => {
38
+ expect(claimToScope({ active: true, groups: ['celilo-admins'] }, 'celilo-admins')).toBe(
39
+ ADMIN_SCOPE,
40
+ );
41
+ });
42
+
43
+ test('non-admin identity → null (owner table is ce-1ch)', () => {
44
+ expect(claimToScope({ active: true, groups: ['users'] }, 'celilo-admins')).toBeNull();
45
+ expect(claimToScope({ active: true }, 'celilo-admins')).toBeNull();
46
+ });
47
+ });
48
+
49
+ describe('introspectionConfigFromEnv', () => {
50
+ const KEYS = [
51
+ 'OIDC_INTROSPECTION_ENDPOINT',
52
+ 'OIDC_CLIENT_ID',
53
+ 'OIDC_CLIENT_SECRET',
54
+ 'REGISTRY_ADMIN_GROUP',
55
+ 'REGISTRY_PUBLISHER_GROUP',
56
+ ];
57
+ afterEach(() => {
58
+ for (const k of KEYS) delete process.env[k];
59
+ });
60
+
61
+ test('null when any required var is missing', () => {
62
+ expect(introspectionConfigFromEnv()).toBeNull();
63
+ process.env.OIDC_INTROSPECTION_ENDPOINT = 'https://idp.test/introspect/';
64
+ process.env.OIDC_CLIENT_ID = 'cid';
65
+ // secret still missing
66
+ expect(introspectionConfigFromEnv()).toBeNull();
67
+ });
68
+
69
+ test('config when all required vars present; admin group defaults', () => {
70
+ process.env.OIDC_INTROSPECTION_ENDPOINT = 'https://idp.test/introspect/';
71
+ process.env.OIDC_CLIENT_ID = 'cid';
72
+ process.env.OIDC_CLIENT_SECRET = 'sec';
73
+ expect(introspectionConfigFromEnv()).toEqual({
74
+ endpoint: 'https://idp.test/introspect/',
75
+ clientId: 'cid',
76
+ clientSecret: 'sec',
77
+ adminGroup: 'celilo-admins',
78
+ publisherGroup: 'celilo-authors',
79
+ });
80
+ process.env.REGISTRY_ADMIN_GROUP = 'ops';
81
+ expect(introspectionConfigFromEnv()?.adminGroup).toBe('ops');
82
+ process.env.REGISTRY_PUBLISHER_GROUP = 'authors';
83
+ expect(introspectionConfigFromEnv()?.publisherGroup).toBe('authors');
84
+ });
85
+ });
86
+
87
+ describe('IntrospectionVerifier.scopeOf', () => {
88
+ const verifier = new IntrospectionVerifier(CONFIG);
89
+
90
+ test('active admin-group token → ADMIN_SCOPE', async () => {
91
+ stubFetch(() =>
92
+ introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'] }),
93
+ );
94
+ expect(await verifier.scopeOf('Bearer idp-token')).toBe(ADMIN_SCOPE);
95
+ });
96
+
97
+ test('active non-admin token → null', async () => {
98
+ stubFetch(() => introspectResponse({ active: true, sub: 'bob', groups: ['users'] }));
99
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
100
+ });
101
+
102
+ test('active:false → null (revoked at idp)', async () => {
103
+ stubFetch(() => introspectResponse({ active: false }));
104
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
105
+ });
106
+
107
+ test('expired exp → null even if active', async () => {
108
+ const pastSec = Math.floor(Date.now() / 1000) - 60;
109
+ stubFetch(() =>
110
+ introspectResponse({ active: true, groups: ['celilo-admins'], exp: pastSec }),
111
+ );
112
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
113
+ });
114
+
115
+ test('future exp → honored', async () => {
116
+ const futureSec = Math.floor(Date.now() / 1000) + 3600;
117
+ stubFetch(() =>
118
+ introspectResponse({ active: true, groups: ['celilo-admins'], exp: futureSec }),
119
+ );
120
+ expect(await verifier.scopeOf('idp-token')).toBe(ADMIN_SCOPE);
121
+ });
122
+
123
+ test('non-2xx response → null (fail closed)', async () => {
124
+ stubFetch(() => introspectResponse({ error: 'boom' }, 500));
125
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
126
+ });
127
+
128
+ test('fetch throws → null (fail closed)', async () => {
129
+ stubFetch(() => {
130
+ throw new Error('connection refused');
131
+ });
132
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
133
+ });
134
+
135
+ test('malformed body (no active field) → null', async () => {
136
+ stubFetch(() => introspectResponse({ sub: 'alice' }));
137
+ expect(await verifier.scopeOf('idp-token')).toBeNull();
138
+ });
139
+
140
+ test('empty header → null without calling the idp', async () => {
141
+ let called = false;
142
+ stubFetch(() => {
143
+ called = true;
144
+ return introspectResponse({ active: true, groups: ['celilo-admins'] });
145
+ });
146
+ expect(await verifier.scopeOf('')).toBeNull();
147
+ expect(called).toBe(false);
148
+ });
149
+
150
+ test('authenticates with HTTP Basic client creds; sends the token in the body', async () => {
151
+ let seenAuth = '';
152
+ let seenBody = '';
153
+ stubFetch((_url, init) => {
154
+ seenAuth = new Headers(init.headers).get('Authorization') ?? '';
155
+ seenBody = String(init.body ?? '');
156
+ return introspectResponse({ active: true, groups: ['celilo-admins'] });
157
+ });
158
+ await verifier.scopeOf('Bearer the-token');
159
+ const expected = `Basic ${Buffer.from('celilo-registry:super-secret').toString('base64')}`;
160
+ expect(seenAuth).toBe(expected);
161
+ expect(seenBody).toContain('token=the-token');
162
+ });
163
+
164
+ test('does not log the token or client secret on failure', async () => {
165
+ const logs: string[] = [];
166
+ const realErr = console.error;
167
+ console.error = (...args: unknown[]) => {
168
+ logs.push(args.map(String).join(' '));
169
+ };
170
+ try {
171
+ stubFetch(() => {
172
+ throw new Error('boom');
173
+ });
174
+ await verifier.scopeOf('Bearer top-secret-token');
175
+ } finally {
176
+ console.error = realErr;
177
+ }
178
+ const joined = logs.join('\n');
179
+ expect(joined).not.toContain('top-secret-token');
180
+ expect(joined).not.toContain('super-secret');
181
+ });
182
+ });
183
+
184
+ describe('IntrospectionVerifier.identify (ce-1ch)', () => {
185
+ const verifier = new IntrospectionVerifier(CONFIG);
186
+
187
+ test('admin-group member → isAdmin, group=adminGroup', async () => {
188
+ stubFetch(() => introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'] }));
189
+ expect(await verifier.identify('Bearer t')).toEqual({
190
+ sub: 'alice',
191
+ isAdmin: true,
192
+ isPublisher: false,
193
+ group: 'celilo-admins',
194
+ });
195
+ });
196
+
197
+ test('publisher-group member → isPublisher, group=publisherGroup', async () => {
198
+ stubFetch(() => introspectResponse({ active: true, sub: 'bob', groups: ['celilo-authors'] }));
199
+ expect(await verifier.identify('Bearer t')).toEqual({
200
+ sub: 'bob',
201
+ isAdmin: false,
202
+ isPublisher: true,
203
+ group: 'celilo-authors',
204
+ });
205
+ });
206
+
207
+ test('member of neither publish group → verified but no group', async () => {
208
+ stubFetch(() => introspectResponse({ active: true, sub: 'carol', groups: ['users'] }));
209
+ expect(await verifier.identify('Bearer t')).toEqual({
210
+ sub: 'carol',
211
+ isAdmin: false,
212
+ isPublisher: false,
213
+ group: undefined,
214
+ });
215
+ });
216
+
217
+ test('admin with no sub → identified (admins publish without an ownership anchor)', async () => {
218
+ stubFetch(() => introspectResponse({ active: true, groups: ['celilo-admins'] }));
219
+ expect(await verifier.identify('Bearer t')).toEqual({
220
+ sub: undefined,
221
+ isAdmin: true,
222
+ isPublisher: false,
223
+ group: 'celilo-admins',
224
+ });
225
+ });
226
+
227
+ test('active:false → null (fail closed)', async () => {
228
+ stubFetch(() => introspectResponse({ active: false }));
229
+ expect(await verifier.identify('Bearer t')).toBeNull();
230
+ });
231
+
232
+ test('expired exp → null even if active', async () => {
233
+ const pastSec = Math.floor(Date.now() / 1000) - 60;
234
+ stubFetch(() =>
235
+ introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'], exp: pastSec }),
236
+ );
237
+ expect(await verifier.identify('Bearer t')).toBeNull();
238
+ });
239
+
240
+ test('admin group is configurable, not a literal', async () => {
241
+ const custom = new IntrospectionVerifier({ ...CONFIG, adminGroup: 'ops', publisherGroup: 'devs' });
242
+ stubFetch(() => introspectResponse({ active: true, sub: 'dan', groups: ['ops'] }));
243
+ const id = await custom.identify('Bearer t');
244
+ expect(id?.isAdmin).toBe(true);
245
+ expect(id?.group).toBe('ops');
246
+ });
247
+ });
@@ -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
+ });