@celilo/e2e 0.7.14 → 0.7.16

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.
@@ -10,8 +10,8 @@ zone:
10
10
  file: "/config/celilo.computer.zone"
11
11
  storage: "/var/lib/knot"
12
12
 
13
- - domain: "lunacycle.net"
14
- file: "/config/lunacycle.net.zone"
13
+ - domain: "example.net"
14
+ file: "/config/example.net.zone"
15
15
  storage: "/var/lib/knot"
16
16
 
17
17
  - domain: "park-your-domain.com"
@@ -7,5 +7,5 @@ $TTL 86400
7
7
 
8
8
  net. IN NS tld-dns.lab.
9
9
 
10
- lunacycle IN NS ns1.lunacycle.net.
11
- ns1.lunacycle.net. IN A 100.64.0.55
10
+ example IN NS ns1.example.net.
11
+ ns1.example.net. IN A 100.64.0.55
@@ -1,7 +1,7 @@
1
- $ORIGIN lunacycle.net.
1
+ $ORIGIN example.net.
2
2
  $TTL 300
3
3
 
4
- @ IN SOA ns1.lunacycle.net. admin.lunacycle.net. (
4
+ @ IN SOA ns1.example.net. admin.example.net. (
5
5
  2024020108 300 60 604800 300
6
6
  )
7
7
 
@@ -10,7 +10,7 @@ $TTL 300
10
10
  ; private IPs are unreachable from outside. All public-facing names
11
11
  ; resolve to the firewall's external IP (100.100.0.100), which DNATs
12
12
  ; inbound to the right internal host.
13
- @ IN NS ns1.lunacycle.net.
13
+ @ IN NS ns1.example.net.
14
14
  ns1 IN A 100.64.0.55
15
15
  @ IN A 100.100.0.100
16
16
  www IN A 100.100.0.100
@@ -7,5 +7,19 @@ ip route add default via 172.30.0.1
7
7
 
8
8
  rm -f /run/squid.pid
9
9
 
10
+ # Initialize the cache swap directories before starting Squid. The image's
11
+ # build-time `squid -z` can leave an INCOMPLETE L1/L2 set (observed: 00–0C
12
+ # present, 0D–0F missing); on a fresh container Squid then FATALs with "Failed
13
+ # to verify one of the swap directories" and exits. With Squid dead, the
14
+ # transparent-proxy REDIRECT (:80→3128, :443→3129) forwards every apt fetch to
15
+ # a closed port → "connection refused" — which surfaced as flaky, non-warming
16
+ # `apt install` failures during module deploys (e.g. knot). `squid -z` is
17
+ # idempotent (creates only missing dirs). `--foreground` is REQUIRED: a bare
18
+ # `squid -z` daemonizes and lingers (holding /run/squid.pid), so the following
19
+ # `squid -N` would FATAL "Squid is already running"; with --foreground, -z runs
20
+ # synchronously, removes the pid file, and exits clean.
21
+ squid --foreground -z -f /etc/squid/squid.conf
22
+ rm -f /run/squid.pid
23
+
10
24
  echo "forward-proxy ready on ports 3128 (HTTP) / 3129 (HTTPS)"
11
25
  exec squid -N -f /etc/squid/squid.conf
@@ -34,11 +34,11 @@ stub-zone:
34
34
  stub-addr: 100.64.0.55
35
35
 
36
36
  stub-zone:
37
- name: "lunacycle.net."
37
+ name: "example.net."
38
38
  stub-addr: 100.64.0.55
39
39
 
40
40
  # celilo.computer hosts the website + install.sh (served by the website-sim).
41
- # Without this stub it falls through to the real internet (71.36.115.155), so the
41
+ # Without this stub it falls through to the real internet (203.0.113.11), so the
42
42
  # website-sim's ACME challenge can't be validated in-topology and the bake's
43
43
  # `curl https://celilo.computer/install.sh` never sees a cert. Covers
44
44
  # www.celilo.computer too. (ISS-0063)
@@ -19,6 +19,19 @@ iptables -P FORWARD ACCEPT
19
19
 
20
20
  # Start Squid transparent proxy (runs on fw-ext itself)
21
21
  rm -f /run/squid.pid
22
+ # Complete the cache swap directories before starting. The image's build-time
23
+ # `squid -z` (Dockerfile.router) is killed after a fixed `sleep 1`, which can
24
+ # interrupt it and leave an INCOMPLETE L1/L2 set (observed: 00–0C present,
25
+ # 0D–0F missing). `squid -N` then FATALs ("Failed to verify one of the swap
26
+ # directories") and never binds — so the transparent-proxy REDIRECT below sends
27
+ # every apt fetch to a closed port and target deploys fail with intermittent,
28
+ # non-warming "connection refused" (e.g. knot's `apt install`). `squid -z` is
29
+ # idempotent, so running it here guarantees a complete tree. `--foreground` is
30
+ # REQUIRED: a bare `squid -z` daemonizes and lingers (holding /run/squid.pid),
31
+ # so the following `squid -N` would FATAL "Squid is already running"; with
32
+ # --foreground, -z runs synchronously, removes the pid file, and exits clean.
33
+ squid --foreground -z -f /etc/squid/squid.conf
34
+ rm -f /run/squid.pid
22
35
  squid -N -f /etc/squid/squid.conf &
23
36
 
24
37
  # Wait for Squid to be listening on both 3128 (HTTP) and 3129 (HTTPS)
@@ -0,0 +1,60 @@
1
+ #!/bin/bash
2
+ # Observer vantage setup (ISS-0117).
3
+ #
4
+ # Installs the routing profile that makes this container a FAITHFUL vantage — the
5
+ # load-bearing part of the framework. A vantage's correctness is its routing table:
6
+ # an internalDevice must reach a fronted service only via the firewall natIp, never a
7
+ # segmented-zone container IP directly. Driven by env from the compose generator:
8
+ # OBSERVER_GATEWAY - default route (the home router for internalDevice, the zone
9
+ # firewall for dmz/app/secure, fw-ext for publicInternet)
10
+ # OBSERVER_INTERZONE - "1" to add the management-style explicit inter-zone routes
11
+ # (the all-VLAN trap). "0" for every faithful consumer vantage.
12
+ # OBSERVER_RESOLVERS - space-separated nameservers for /etc/resolv.conf, in order
13
+ set -u
14
+
15
+ GATEWAY="${OBSERVER_GATEWAY:?OBSERVER_GATEWAY is required}"
16
+ INTERZONE="${OBSERVER_INTERZONE:-0}"
17
+ RESOLVERS="${OBSERVER_RESOLVERS:-100.100.0.1}"
18
+
19
+ echo "Observer starting: gateway=${GATEWAY} interzone=${INTERZONE} resolvers='${RESOLVERS}'"
20
+
21
+ # --- Default route ---
22
+ # Docker seeds a default via the network's own gateway; replace it with the gateway a
23
+ # real device at this location would use.
24
+ ip route del default 2>/dev/null || true
25
+ ip route add default via "${GATEWAY}"
26
+
27
+ # --- Inter-zone routes (the management trap; only when explicitly requested) ---
28
+ # A real consumer vantage NEVER has these — that is the whole point. Present only so a
29
+ # `management`-style all-VLAN box can be modelled deliberately.
30
+ if [ "${INTERZONE}" = "1" ]; then
31
+ ip route add 10.0.10.0/24 via 192.168.0.254 2>/dev/null || true # dmz via fw-main
32
+ ip route add 10.0.20.0/24 via 192.168.0.254 2>/dev/null || true # app via fw-main
33
+ ip route add 10.0.30.0/24 via 192.168.0.254 2>/dev/null || true # secure via fw-main
34
+ fi
35
+
36
+ # --- Resolver ---
37
+ : > /etc/resolv.conf
38
+ for ns in ${RESOLVERS}; do
39
+ echo "nameserver ${ns}" >> /etc/resolv.conf
40
+ done
41
+
42
+ # --- Pebble runtime ACME root CA ---
43
+ # Pebble mints a fresh root each startup, so the build-time CA can't verify certs Caddy
44
+ # obtained at runtime. Fetch the live root so curl/openssl can validate real chains —
45
+ # the no-self-signed / CA-provenance assertions depend on a correct trust store.
46
+ echo "Fetching Pebble ACME root CA..."
47
+ for _ in $(seq 1 30); do
48
+ if curl -sk https://100.64.0.100:15000/roots/0 -o /usr/local/share/ca-certificates/pebble-acme-root.crt 2>/dev/null; then
49
+ if [ -s /usr/local/share/ca-certificates/pebble-acme-root.crt ]; then
50
+ update-ca-certificates 2>/dev/null
51
+ echo "Pebble ACME root CA installed"
52
+ break
53
+ fi
54
+ fi
55
+ sleep 2
56
+ done
57
+
58
+ echo "Observer ready: $(hostname)"
59
+ ip route
60
+ sleep infinity
@@ -9,9 +9,9 @@ CADDY_IP="${CADDY_IP:-10.0.10.10}"
9
9
  # DOMAINS: comma-separated list of e2e test domains that should be
10
10
  # split-horizon redirected to caddy. Default covers the canonical
11
11
  # (iamtheinternet.org) plus the cross-domain test's second domain
12
- # (lunacycle.net). Tests can override via DEFAULT_GATEWAY/DOMAINS env
12
+ # (example.net). Tests can override via DEFAULT_GATEWAY/DOMAINS env
13
13
  # vars in docker-compose.
14
- DOMAINS="${DOMAINS:-iamtheinternet.org,lunacycle.net}"
14
+ DOMAINS="${DOMAINS:-iamtheinternet.org,example.net}"
15
15
 
16
16
  # Routing — reach comcast-resolver (100.100.0.1) via fw-main or fw-isp.
17
17
  # DEFAULT_GATEWAY is set by docker-compose from the topology config.
@@ -0,0 +1,26 @@
1
+ # Observer — a passive vantage-point spy (ISS-0117).
2
+ #
3
+ # Carries the full probe toolbox (dig, curl, openssl, nc, ping, ip) so a single image
4
+ # can be injected at any network location and assert resolve/reach/TLS from that seat.
5
+ # It runs NO services and deploys NO modules — it never perturbs the system under test.
6
+ # Its faithfulness as a vantage comes from the routing profile installed at startup
7
+ # (see config/routing/observer-setup.sh), driven by the OBSERVER_* env the compose
8
+ # generator sets per vantage.
9
+ FROM ubuntu:22.04
10
+
11
+ ENV DEBIAN_FRONTEND=noninteractive
12
+
13
+ RUN apt-get update && apt-get install -y \
14
+ dnsutils \
15
+ curl \
16
+ openssl \
17
+ netcat-openbsd \
18
+ iputils-ping \
19
+ iproute2 \
20
+ ca-certificates \
21
+ && rm -rf /var/lib/apt/lists/*
22
+
23
+ COPY config/routing/observer-setup.sh /startup.sh
24
+ RUN chmod +x /startup.sh
25
+
26
+ CMD ["/startup.sh"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "dependencies": {
39
39
  "@celilo/cli-display": "^0.1.9",
40
- "@celilo/event-bus": "^0.1.6",
40
+ "@celilo/event-bus": "^0.1.7",
41
41
  "yaml": "^2.8.0",
42
42
  "zod": "^3.24.1"
43
43
  },
@@ -1,50 +1,76 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { TokenAuth } from './auth';
2
+ import { ADMIN_SCOPE, TokenAuth, hashToken } from './auth';
3
3
 
4
- describe('TokenAuth', () => {
5
- test('empty tokens list → no tokens, verify always false', () => {
4
+ describe('TokenAuth (package-scoped — ISS-0140)', () => {
5
+ test('empty specs → no tokens; nothing authorizes', () => {
6
6
  const auth = new TokenAuth([]);
7
7
  expect(auth.hasTokens()).toBe(false);
8
- expect(auth.verify('anything')).toBe(false);
8
+ expect(auth.authorize('anything', 'caddy')).toBe(false);
9
+ expect(auth.isAdmin('anything')).toBe(false);
9
10
  });
10
11
 
11
- test('verify returns true for matching token, false for non-matching', () => {
12
- const auth = new TokenAuth(['secret-token']);
13
- expect(auth.verify('secret-token')).toBe(true);
14
- expect(auth.verify('wrong-token')).toBe(false);
12
+ test('admin-scoped token publishes ANY package', () => {
13
+ const auth = new TokenAuth([{ token: 'admin-tok', scope: ADMIN_SCOPE }]);
14
+ expect(auth.authorize('admin-tok', 'caddy')).toBe(true);
15
+ expect(auth.authorize('admin-tok', 'lunacycle')).toBe(true);
16
+ expect(auth.isAdmin('admin-tok')).toBe(true);
17
+ });
18
+
19
+ test('package-scoped token publishes ONLY its package', () => {
20
+ const auth = new TokenAuth([{ token: 'luna-tok', scope: 'lunacycle' }]);
21
+ expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
22
+ expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
23
+ // A scoped token is NOT an admin token — it cannot mint.
24
+ expect(auth.isAdmin('luna-tok')).toBe(false);
25
+ });
26
+
27
+ test('unknown token never authorizes', () => {
28
+ const auth = new TokenAuth([{ token: 'known', scope: 'lunacycle' }]);
29
+ expect(auth.authorize('unknown', 'lunacycle')).toBe(false);
15
30
  });
16
31
 
17
32
  test('tokens are stored hashed, not in cleartext', () => {
18
- const auth = new TokenAuth(['secret-token']);
19
- // Internal property access is a smoke check — if tokens were stored raw,
20
- // 'secret-token' would appear in the Set. Only hashes should be present.
21
- const rawDump = JSON.stringify(auth);
22
- expect(rawDump).not.toContain('secret-token');
33
+ const auth = new TokenAuth([{ token: 'secret-token', scope: ADMIN_SCOPE }]);
34
+ expect(JSON.stringify(auth)).not.toContain('secret-token');
23
35
  });
24
36
 
25
- test('verify trims whitespace to tolerate header formatting variance', () => {
26
- const auth = new TokenAuth(['clean']);
27
- expect(auth.verify(' clean ')).toBe(true);
37
+ test('tolerates a Bearer prefix and surrounding whitespace', () => {
38
+ const auth = new TokenAuth([{ token: 'clean', scope: ADMIN_SCOPE }]);
39
+ expect(auth.authorize(' clean ', 'caddy')).toBe(true);
40
+ expect(auth.authorize('Bearer clean', 'caddy')).toBe(true);
28
41
  });
29
42
 
30
- test('verify rejects empty string', () => {
31
- const auth = new TokenAuth(['secret']);
32
- expect(auth.verify('')).toBe(false);
43
+ test('empty header rejected', () => {
44
+ const auth = new TokenAuth([{ token: 'secret', scope: ADMIN_SCOPE }]);
45
+ expect(auth.authorize('', 'caddy')).toBe(false);
46
+ expect(auth.scopeOf('')).toBe(null);
33
47
  });
34
48
 
35
- test('multiple tokens — any match verifies', () => {
36
- const auth = new TokenAuth(['t1', 't2', 't3']);
37
- expect(auth.verify('t1')).toBe(true);
38
- expect(auth.verify('t2')).toBe(true);
39
- expect(auth.verify('t3')).toBe(true);
40
- expect(auth.verify('t4')).toBe(false);
49
+ test('addHashed / removeHashed manage minted tokens at runtime', () => {
50
+ const auth = new TokenAuth([]);
51
+ const hash = hashToken('minted-tok');
52
+ auth.addHashed(hash, 'lunacycle');
53
+ expect(auth.authorize('minted-tok', 'lunacycle')).toBe(true);
54
+ auth.removeHashed(hash);
55
+ expect(auth.authorize('minted-tok', 'lunacycle')).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe('TokenAuth.fromEnv (PUBLISH_TOKENS format)', () => {
60
+ test('bare line → admin scope; "token pkg" line → scoped', () => {
61
+ process.env.PUBLISH_TOKENS = 'admin-tok\nluna-tok lunacycle\n';
62
+ const auth = TokenAuth.fromEnv();
63
+ expect(auth.isAdmin('admin-tok')).toBe(true);
64
+ expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
65
+ expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
66
+ process.env.PUBLISH_TOKENS = '';
41
67
  });
42
68
 
43
- test('empty lines in input are ignored', () => {
44
- const auth = new TokenAuth(['t1', '', ' ', 't2']);
69
+ test('blank lines ignored', () => {
70
+ process.env.PUBLISH_TOKENS = '\n \nt1\n';
71
+ const auth = TokenAuth.fromEnv();
45
72
  expect(auth.hasTokens()).toBe(true);
46
- expect(auth.verify('t1')).toBe(true);
47
- expect(auth.verify('t2')).toBe(true);
48
- expect(auth.verify('')).toBe(false);
73
+ expect(auth.isAdmin('t1')).toBe(true);
74
+ process.env.PUBLISH_TOKENS = '';
49
75
  });
50
76
  });
@@ -1,36 +1,105 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
3
  /**
4
- * SHA-256 hashed token auth. Tokens never leave the caller's memory in cleartext
5
- * once the server starts — the set stores only hashes. Rotation = restart with
6
- * a new PUBLISH_TOKENS env var.
4
+ * Package-scoped token auth (build-bus Phase 3, ISS-0140).
5
+ *
6
+ * Each token carries a SCOPE — either a single package name (the token may
7
+ * publish only that module) or the admin scope `*` (publish anything + mint
8
+ * scoped tokens). Tokens are stored SHA-256 hashed; cleartext never lives in
9
+ * the auth set once the server starts.
10
+ *
11
+ * PUBLISH_TOKENS env (newline-separated), per line:
12
+ * <token> → admin scope `*` (publish any package, mint tokens)
13
+ * <token> <package> → scoped to that one package
14
+ *
15
+ * A bare `<token>` line is the admin/bootstrap token — this preserves the
16
+ * pre-scoping behavior (any configured token publishes anything) AND is the
17
+ * privilege that the `registry_publish` capability uses to mint per-repo
18
+ * scoped tokens. Minted scoped tokens are added at runtime via {@link addHashed}
19
+ * (loaded from the persisted store) and removed via {@link removeHashed}.
7
20
  */
21
+
22
+ /** Admin scope: publish any package + mint scoped tokens. */
23
+ export const ADMIN_SCOPE = '*';
24
+
25
+ export interface TokenSpec {
26
+ token: string;
27
+ /** Package name, or {@link ADMIN_SCOPE}. */
28
+ scope: string;
29
+ }
30
+
31
+ /** SHA-256 of a token (trimmed). The persisted scoped-token store hashes too. */
32
+ export function hashToken(token: string): string {
33
+ return createHash('sha256').update(token.trim()).digest('hex');
34
+ }
35
+
36
+ /** Strip an optional `Bearer ` prefix and surrounding whitespace from a header. */
37
+ function bareToken(header: string): string {
38
+ return header.replace(/^Bearer\s+/i, '').trim();
39
+ }
40
+
8
41
  export class TokenAuth {
9
- private readonly hashedTokens: Set<string>;
42
+ /** hash → scope. */
43
+ private readonly scopeByHash = new Map<string, string>();
10
44
 
11
- constructor(rawTokens: string[]) {
12
- this.hashedTokens = new Set(
13
- rawTokens
14
- .map((t) => t.trim())
15
- .filter(Boolean)
16
- .map((t) => createHash('sha256').update(t).digest('hex')),
17
- );
45
+ constructor(specs: TokenSpec[]) {
46
+ for (const s of specs) this.addRaw(s.token, s.scope);
18
47
  }
19
48
 
49
+ /**
50
+ * Parse the `PUBLISH_TOKENS` env format into the scoped auth set. Each line is
51
+ * `<token>` (admin scope) or `<token> <package>` (scoped to one package).
52
+ */
20
53
  static fromEnv(): TokenAuth {
21
54
  const raw = process.env.PUBLISH_TOKENS ?? '';
22
- const tokens = raw.split('\n').filter(Boolean);
23
- return new TokenAuth(tokens);
55
+ const specs: TokenSpec[] = [];
56
+ for (const line of raw.split('\n')) {
57
+ const trimmed = line.trim();
58
+ if (!trimmed) continue;
59
+ const [token, scope] = trimmed.split(/\s+/, 2);
60
+ specs.push({ token, scope: scope || ADMIN_SCOPE });
61
+ }
62
+ return new TokenAuth(specs);
63
+ }
64
+
65
+ /** Add a cleartext token (hashes it). Empty tokens are ignored. */
66
+ addRaw(rawToken: string, scope: string): void {
67
+ const t = rawToken.trim();
68
+ if (!t) return;
69
+ this.scopeByHash.set(hashToken(t), scope.trim() || ADMIN_SCOPE);
70
+ }
71
+
72
+ /** Add an already-hashed token (used when loading the persisted minted-token store). */
73
+ addHashed(hash: string, scope: string): void {
74
+ this.scopeByHash.set(hash, scope);
75
+ }
76
+
77
+ /** Remove a token by its hash (token revocation). */
78
+ removeHashed(hash: string): void {
79
+ this.scopeByHash.delete(hash);
24
80
  }
25
81
 
26
- /** If no tokens are configured, the server treats every publish as unauthorized. */
82
+ /** If no tokens are configured, the server treats every write as unauthorized. */
27
83
  hasTokens(): boolean {
28
- return this.hashedTokens.size > 0;
84
+ return this.scopeByHash.size > 0;
85
+ }
86
+
87
+ /** The scope of a token (from an Authorization header value), or null if unknown. */
88
+ scopeOf(header: string): string | null {
89
+ const t = bareToken(header);
90
+ if (!t) return null;
91
+ return this.scopeByHash.get(hashToken(t)) ?? null;
92
+ }
93
+
94
+ /** True when the token may publish/yank the given package (admin or exact scope). */
95
+ authorize(header: string, pkg: string): boolean {
96
+ const scope = this.scopeOf(header);
97
+ if (scope === null) return false;
98
+ return scope === ADMIN_SCOPE || scope === pkg;
29
99
  }
30
100
 
31
- verify(token: string): boolean {
32
- if (!token) return false;
33
- const hashed = createHash('sha256').update(token.trim()).digest('hex');
34
- return this.hashedTokens.has(hashed);
101
+ /** True when the token is an admin token (scope `*`) — required to mint scoped tokens. */
102
+ isAdmin(header: string): boolean {
103
+ return this.scopeOf(header) === ADMIN_SCOPE;
35
104
  }
36
105
  }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { hashToken } from './auth';
3
+ import {
4
+ type ScopedTokenEntry,
5
+ type ScopedTokenPersistence,
6
+ ScopedTokenStore,
7
+ } from './scoped-token-store';
8
+
9
+ /** In-memory persistence so the store is testable without the filesystem. */
10
+ function memoryPersistence(seed: ScopedTokenEntry[] = []): ScopedTokenPersistence & {
11
+ saved: ScopedTokenEntry[];
12
+ } {
13
+ const state = { saved: [...seed] };
14
+ return {
15
+ saved: state.saved,
16
+ load() {
17
+ return [...state.saved];
18
+ },
19
+ save(entries) {
20
+ state.saved.length = 0;
21
+ state.saved.push(...entries);
22
+ },
23
+ };
24
+ }
25
+
26
+ /** Deterministic token generator (avoids randomness in assertions). */
27
+ function seqGen() {
28
+ let n = 0;
29
+ return () => `tok-${++n}`;
30
+ }
31
+
32
+ describe('ScopedTokenStore.mint (ISS-0140)', () => {
33
+ test('mints a scoped token and persists only the hash', () => {
34
+ const p = memoryPersistence();
35
+ const store = new ScopedTokenStore(p, seqGen(), () => '2026-06-20T00:00:00Z');
36
+ const { token, entry, revokedHashes } = store.mint('celilo/lunacycle', 'lunacycle');
37
+
38
+ expect(token).toBe('tok-1');
39
+ expect(entry.scope).toBe('lunacycle');
40
+ expect(entry.repo).toBe('celilo/lunacycle');
41
+ expect(entry.hash).toBe(hashToken('tok-1'));
42
+ expect(revokedHashes).toEqual([]);
43
+ // Persisted form holds the hash, never the cleartext token.
44
+ expect(JSON.stringify(p.saved)).not.toContain('tok-1');
45
+ expect(p.saved).toHaveLength(1);
46
+ });
47
+
48
+ test('re-minting for the same repo rotates the token (reconcile)', () => {
49
+ const p = memoryPersistence();
50
+ const store = new ScopedTokenStore(p, seqGen(), () => 't');
51
+ const first = store.mint('celilo/lunacycle', 'lunacycle');
52
+ const second = store.mint('celilo/lunacycle', 'lunacycle');
53
+
54
+ expect(second.token).toBe('tok-2');
55
+ expect(second.revokedHashes).toEqual([first.entry.hash]);
56
+ // Still exactly one active token for the repo.
57
+ expect(store.list().filter((e) => e.repo === 'celilo/lunacycle')).toHaveLength(1);
58
+ expect(store.list()).toHaveLength(1);
59
+ });
60
+
61
+ test('different repos coexist independently', () => {
62
+ const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
63
+ store.mint('celilo/lunacycle', 'lunacycle');
64
+ store.mint('celilo/caddy', 'caddy');
65
+ expect(store.list()).toHaveLength(2);
66
+ });
67
+ });
68
+
69
+ describe('ScopedTokenStore.revoke', () => {
70
+ test('removes all tokens for a repo and returns their hashes', () => {
71
+ const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
72
+ const { entry } = store.mint('celilo/lunacycle', 'lunacycle');
73
+ const removed = store.revoke('celilo/lunacycle');
74
+ expect(removed).toEqual([entry.hash]);
75
+ expect(store.list()).toHaveLength(0);
76
+ });
77
+
78
+ test('revoking an unknown repo is a no-op', () => {
79
+ const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
80
+ expect(store.revoke('celilo/nope')).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe('ScopedTokenStore startup load', () => {
85
+ test('loads persisted entries on construction', () => {
86
+ const seed: ScopedTokenEntry[] = [
87
+ { hash: hashToken('x'), scope: 'caddy', repo: 'celilo/caddy', mintedAt: 't' },
88
+ ];
89
+ const store = new ScopedTokenStore(memoryPersistence(seed), seqGen(), () => 't');
90
+ expect(store.list()).toHaveLength(1);
91
+ expect(store.list()[0]?.repo).toBe('celilo/caddy');
92
+ });
93
+ });
@@ -0,0 +1,110 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { hashToken } from './auth';
5
+
6
+ /**
7
+ * Persisted store of minted, package-scoped publish tokens (build-bus Phase 3,
8
+ * ISS-0140). The `registry_publish` capability mints one token per registered
9
+ * repo, scoped to the single package that repo publishes — so a compromised
10
+ * runner can ship only its own module, never another.
11
+ *
12
+ * Only the token HASH is persisted (never cleartext) — the raw token is returned
13
+ * exactly once at mint time, for the caller to set as the repo's Forgejo Actions
14
+ * secret. At startup the server loads this store into its {@link TokenAuth} via
15
+ * `addHashed`; mint/revoke update both the in-memory auth and this file, so a
16
+ * minted token works without a server restart.
17
+ */
18
+
19
+ export interface ScopedTokenEntry {
20
+ /** SHA-256 of the raw token. */
21
+ hash: string;
22
+ /** Package this token may publish. */
23
+ scope: string;
24
+ /** Repo path on the source forge this token was minted for (the reconcile key). */
25
+ repo: string;
26
+ /** ISO timestamp of mint. */
27
+ mintedAt: string;
28
+ }
29
+
30
+ export interface MintResult {
31
+ /** The raw token — returned ONCE; never persisted in cleartext. */
32
+ token: string;
33
+ entry: ScopedTokenEntry;
34
+ /** Hashes of tokens this mint superseded for the same repo (revoke them in auth). */
35
+ revokedHashes: string[];
36
+ }
37
+
38
+ /** I/O seam (Rule 2.3) so the store is unit-testable without the filesystem. */
39
+ export interface ScopedTokenPersistence {
40
+ load(): ScopedTokenEntry[];
41
+ save(entries: ScopedTokenEntry[]): void;
42
+ }
43
+
44
+ /** File-backed persistence: a JSON array at `filePath`, tolerant of a missing file. */
45
+ export function fileScopedTokenPersistence(filePath: string): ScopedTokenPersistence {
46
+ return {
47
+ load() {
48
+ if (!existsSync(filePath)) return [];
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
51
+ return Array.isArray(parsed) ? (parsed as ScopedTokenEntry[]) : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ },
56
+ save(entries) {
57
+ mkdirSync(dirname(filePath), { recursive: true });
58
+ writeFileSync(filePath, JSON.stringify(entries, null, 2));
59
+ },
60
+ };
61
+ }
62
+
63
+ function defaultGenToken(): string {
64
+ return `cpt_${randomBytes(32).toString('base64url')}`;
65
+ }
66
+
67
+ export class ScopedTokenStore {
68
+ private entries: ScopedTokenEntry[];
69
+
70
+ constructor(
71
+ private readonly persistence: ScopedTokenPersistence,
72
+ private readonly genToken: () => string = defaultGenToken,
73
+ private readonly now: () => string = () => new Date().toISOString(),
74
+ ) {
75
+ this.entries = persistence.load();
76
+ }
77
+
78
+ list(): ScopedTokenEntry[] {
79
+ return [...this.entries];
80
+ }
81
+
82
+ /**
83
+ * Mint a fresh scoped token for `repo`, reconciling: any existing token for the
84
+ * same repo is superseded (its hash returned in `revokedHashes`). Idempotent at
85
+ * the repo level — re-running rotates the token cleanly.
86
+ */
87
+ mint(repo: string, scope: string): MintResult {
88
+ const revokedHashes = this.entries.filter((e) => e.repo === repo).map((e) => e.hash);
89
+ const token = this.genToken();
90
+ const entry: ScopedTokenEntry = {
91
+ hash: hashToken(token),
92
+ scope,
93
+ repo,
94
+ mintedAt: this.now(),
95
+ };
96
+ this.entries = [...this.entries.filter((e) => e.repo !== repo), entry];
97
+ this.persistence.save(this.entries);
98
+ return { token, entry, revokedHashes };
99
+ }
100
+
101
+ /** Revoke all tokens for `repo`. Returns the removed hashes. */
102
+ revoke(repo: string): string[] {
103
+ const removed = this.entries.filter((e) => e.repo === repo).map((e) => e.hash);
104
+ if (removed.length > 0) {
105
+ this.entries = this.entries.filter((e) => e.repo !== repo);
106
+ this.persistence.save(this.entries);
107
+ }
108
+ return removed;
109
+ }
110
+ }