@celilo/e2e 0.7.15 → 0.7.17

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/AGENTS.md ADDED
@@ -0,0 +1,117 @@
1
+ # AGENTS.md — `@celilo/e2e`
2
+
3
+ `@celilo/e2e` is a **simulated internet in Docker** for end-to-end testing of
4
+ [celilo](https://celilo.computer)-deployed apps. It stands up a DNS hierarchy
5
+ (root → TLD → registrar → recursive resolver), an ACME server (Pebble, standing
6
+ in for Let's Encrypt), an ISP router + customer firewall with real iptables NAT,
7
+ zoned LAN networks, and systemd target machines — then runs celilo *inside* it as
8
+ if it were deploying to a real home lab. This file orients an AI agent to write
9
+ and run those tests. **Read the in-package docs below before searching the web** —
10
+ they are the source of truth.
11
+
12
+ This file ships **inside the npm package**: the `./`-prefixed docs in the doc map
13
+ are installed alongside it (grep them offline). Repo-relative entries point at the
14
+ source repository, which the tarball does not carry.
15
+
16
+ ## Mental model (learn these five things)
17
+
18
+ - **The sim is a faithful internet, not a convenience harness.** Public DNS only
19
+ ever answers with the customer firewall's *external* IP; the firewall DNATs
20
+ inbound to the right internal host. ACME challenges validate over the public
21
+ path. A test that "passes" by short-circuiting any of this is testing a placebo
22
+ — see **Inviolable rules** below.
23
+ - **`network()` builder** — declare zones and the machines in them, `.start()` the
24
+ topology, drive celilo with `.celilo(...)`, assert, then `.stop()`. This is the
25
+ whole library surface (`import { network } from '@celilo/e2e'`).
26
+ - **Zones** — `internal` (home LAN), `dmz`, `app`, `secure` are the customer-side
27
+ networks; `isp-external` / `internet-external` model the public internet. Public
28
+ IPs live only on the public networks; the firewall containers are the only place
29
+ the two address spaces meet (via NAT/DNAT).
30
+ - **Two consumption modes** — as a **library** (`network()` in a `bun test` file)
31
+ or via the **`cele2e` CLI** (`cele2e run <test>`, `build-infra`, `list`). The
32
+ shared infra (`celilo-e2e-shared`) is a *global* Docker compose project,
33
+ independent of which checkout started it.
34
+ - **`build-infra` gate** — anything baked into a Docker image (`docker/`,
35
+ `config/`, `simulators/`, installed package tarballs) needs `cele2e build-infra`
36
+ before it takes effect. Pure `src/*.ts` changes do not (bun runs TS directly).
37
+
38
+ ## Writing a test (the happy path)
39
+
40
+ ```ts
41
+ import { test, expect } from 'bun:test';
42
+ import { network } from '@celilo/e2e';
43
+
44
+ test('caddy serves TLS in the dmz', async () => {
45
+ const net = await network()
46
+ .dmz({ caddy: '10.0.10.10' }) // machine name → zone-side IP
47
+ .start();
48
+ try {
49
+ await net.celilo('module import caddy');
50
+ await net.celilo('module deploy caddy --no-interactive');
51
+ // assert against the deployed service via the public path, not the container IP
52
+ } finally {
53
+ await net.stop();
54
+ }
55
+ });
56
+ ```
57
+
58
+ - Prefer the **fixtures** in `./src/fixtures.ts` (`CADDY_DEPLOYMENT`,
59
+ `FULL_STACK`, `TECHNITIUM_STACK`, …) over hand-rolling topology.
60
+ - Drive **everything through the celilo CLI** or the module's documented surface —
61
+ never `docker exec` to hand-edit a deployed container's config (that's the bug
62
+ you're trying to catch).
63
+ - Poll on the **observable condition** the deploy is meant to satisfy
64
+ (`VantageProbe`, DNS resolution, an HTTP header) — never `setTimeout` to mask a
65
+ race.
66
+
67
+ ## CLI you'll use
68
+
69
+ ```
70
+ cele2e list # enumerate every test (module suites + top-level)
71
+ cele2e build-infra # rebuild Docker images (required after image-baked changes)
72
+ cele2e run <test> [--keep] # run one test; --keep leaves the network up for debugging
73
+ cele2e run --all # full regression (every suite); --all-modules = modules only
74
+ ```
75
+
76
+ For Claude-driven / detached runs, fire via the repo wrapper
77
+ `infra/scripts/cele2e-run.sh <test> [--keep|--build]` (fire-and-forget RUNID/LOG
78
+ contract) rather than calling `cele2e run` from a long-lived watcher.
79
+
80
+ ## Inviolable rules (breaking any turns the suite into a placebo)
81
+
82
+ 1. **No RFC 1918 leaks past the public boundary.** Public DNS never serves an A
83
+ record pointing at `10/8`, `172.16/12`, or `192.168/16` — only the firewall's
84
+ external IP.
85
+ 2. **Don't collapse the topology to fix a timing/connectivity bug.** Fix the
86
+ *deployment ordering* (DNS record / DNAT rule / firewall opening in place
87
+ before the dependent step), not the simulation.
88
+ 3. **Public IPs only on the public networks.** Firewall containers bridge public
89
+ and private — the only place those spaces meet, and only via NAT/DNAT.
90
+ 4. **ACME challenges go through the public path** to the firewall's external IP.
91
+ 5. **DDNS updates modify *public* DNS** with the firewall's external IP, never an
92
+ internal target IP.
93
+
94
+ Cheats (`/etc/hosts` overrides, `iptables -F`, `--insecure` in module code,
95
+ hardcoded internal IPs, `|| true` on a celilo command, sleeping past a race) are
96
+ fine for **bisecting** a failure but are never a **final solution** — confirm the
97
+ hypothesis, then ship the real fix. Full list + rationale in `./README.md` and the
98
+ repo `CLAUDE.md`.
99
+
100
+ ## Doc map (read in this order)
101
+
102
+ Shipped **inside this package** (offline, grep-friendly):
103
+
104
+ - `./README.md` — network architecture, machines, routing, DNS, simulators,
105
+ manual usage. Start here.
106
+ - `./COVERAGE.md` — the array/object/map coverage audit (where N>1 paths are or
107
+ aren't exercised). Consult before adding a multi-element feature test.
108
+ - `./src/index.ts` — the public library exports (`network`, types, probes).
109
+ - `./src/fixtures.ts` — ready-made topology presets to build tests on.
110
+ - `./src/types.ts` — `NetworkHandle`, `Zone`, `MachineSpec`, `Vantage`, and the
111
+ zone subnet/gateway constants.
112
+
113
+ In the **source repo** (not carried in the tarball):
114
+
115
+ - `infra/docs/RUNNING_CELE2E_TESTS.md` — the full operator guide; read before
116
+ running the suite for real.
117
+ - `https://celilo.computer/docs` — hosted docs (LAN; the repo docs lead).
package/COVERAGE.md ADDED
@@ -0,0 +1,60 @@
1
+ # E2E Test Coverage Audit
2
+
3
+ This document tracks how well the e2e suite exercises capability and module
4
+ fields whose type is `array`, `object`, or `map` — anywhere the framework's
5
+ public API admits "more than one of X."
6
+
7
+ ## Why this exists
8
+
9
+ A test that only ever uses N=1 will pass even when the multi-element path is
10
+ broken. The bug fixed by `dns_registrar` 4.0.0 (see
11
+ `apps/celilo/designs/DNS_REGISTRAR_MULTI_DOMAIN.md`) escaped the existing e2e
12
+ suite for exactly this reason: the website-deploy-simple test put caddy, namecheap,
13
+ and the website all on a single domain, and the multi-domain code path was
14
+ never exercised.
15
+
16
+ ## Audit checklist
17
+
18
+ For every capability and every module manifest, identify each field whose
19
+ type is `array`, `object`, or `map`. For each one, ask:
20
+
21
+ 1. Does at least one e2e test exercise this field with **N > 1** distinct
22
+ values?
23
+ 2. Are those values **meaningfully different** (different domains, different
24
+ zones, different ports — not just "two of the same kind of thing")?
25
+ 3. Does the test assert behavior that would be **wrong if only the first
26
+ element worked**?
27
+
28
+ Anywhere all three answers aren't "yes," that's a gap to either close (write
29
+ the test) or document why it's intentionally deferred.
30
+
31
+ ## Coverage table
32
+
33
+ | Field | N>1? | Meaningfully different? | Asserts non-first works? | Status |
34
+ |---|---|---|---|---|
35
+ | `namecheap.domains` | yes (`website-deploy-cross-domain`) | yes (different TLDs: `iamtheinternet.org` + `example.net`) | yes (verifies example.net DNS+TLS while iamtheinternet.org is canonical) | covered |
36
+ | `namecheap.ddns_passwords` (per-domain map) | yes (same test) | yes (distinct passwords per domain) | yes (uses example.net's own password to update its A record) | covered |
37
+ | `caddy.hostnames` | yes (`website-deploy-simple`) | partial (same domain in `website-deploy-simple.test.ts`); yes (cross-TLD in cross-domain test) | yes (cross-domain test serves a hostname that isn't `hostnames[0]`) | covered |
38
+ | `iptables.exposed_services` | unclear — needs audit | — | — | **GAP** |
39
+ | `public_web` registrations across modules | partial (`website-deploy-simple.test.ts` registers auth + website on the same domain) | needs cross-domain variant | partial | **GAP** |
40
+ | `<every secret: object field>` | typically only one key | usually no | — | **AUDIT EACH** |
41
+
42
+ ## Closing the gaps
43
+
44
+ - **`iptables.exposed_services`**: write an e2e that exposes two services
45
+ on the same machine via different ports and verifies both are reachable
46
+ from outside.
47
+ - **`public_web` cross-domain**: extend `website-deploy-simple.test.ts` (or a new
48
+ test) to register routes on two different domains and verify each is
49
+ served by the right backend.
50
+ - **secret object fields**: as modules using these grow (e.g. third-party
51
+ API integrations with multiple keys), add per-key tests.
52
+
53
+ ## New-module checklist
54
+
55
+ When authoring a module, list every field whose type permits multiple
56
+ values. For each, write at least one e2e or integration test that uses
57
+ **N ≥ 2** with meaningfully different values. Add a row to the table
58
+ above (or document why you've deferred it).
59
+
60
+ See `design/MODULE_DEVELOPMENT_GUIDE.md` for the full new-module checklist.
package/bin/e2e-build CHANGED
@@ -22,6 +22,7 @@ NETAPPS_DIR="$E2E_DIR/netapps"
22
22
  cd "$E2E_DIR"
23
23
 
24
24
  GREEN='\033[0;32m'
25
+ YELLOW='\033[0;33m'
25
26
  DIM='\033[2m'
26
27
  BOLD='\033[1m'
27
28
  NC='\033[0m'
@@ -160,6 +161,25 @@ BAKE_END=$(date +%s)
160
161
  echo -e "${GREEN}Baked in $((BAKE_END - BAKE_START))s${NC}"
161
162
  echo ""
162
163
 
164
+ # ── Step 2d: Pre-seed heavy app images into the app-zone preload cache ─────────
165
+ #
166
+ # Heavy application images (authentik server, postgres, redis) are pulled ONCE
167
+ # on the host here and saved to docker-image-cache/, which app-zone target
168
+ # machines bind-mount and load at startup via docker-image-preload.service.
169
+ # Without this the cache is empty on a fresh checkout / the builder, so every
170
+ # app-module deploy pulls GB through the simulated internet — slow enough to
171
+ # time the authentik deploy out (exit 124) and take down forgejo-deploy +
172
+ # full-stack-pipeline (ISS-0154). Best-effort: e2e-cache-images is per-image
173
+ # resilient and the preload service skips a missing tarball, so a transient
174
+ # registry hiccup degrades to a slow pull rather than failing build-infra.
175
+ echo -e "${BOLD}Pre-seeding app-zone image cache...${NC}"
176
+ if bash "$E2E_DIR/bin/e2e-cache-images"; then
177
+ :
178
+ else
179
+ echo -e "${YELLOW}⚠ image cache step failed — app-zone deploys may pull over the sim${NC}" >&2
180
+ fi
181
+ echo ""
182
+
163
183
  # ── Step 3: Optionally save images to tarball ─────────────────────────────────
164
184
 
165
185
  if [ "$1" = "--save" ]; then
@@ -23,6 +23,7 @@ mkdir -p "$CACHE_DIR"
23
23
 
24
24
  BOLD='\033[1m'
25
25
  GREEN='\033[0;32m'
26
+ YELLOW='\033[0;33m'
26
27
  DIM='\033[2m'
27
28
  NC='\033[0m'
28
29
 
@@ -44,12 +45,18 @@ for filename in "${!IMAGES[@]}"; do
44
45
  printf " %-50s " "$image"
45
46
  START=$(date +%s)
46
47
 
47
- docker pull "$image" > /dev/null 2>&1
48
- docker save "$image" -o "$tarball"
49
-
50
- END=$(date +%s)
51
- SIZE=$(du -h "$tarball" | cut -f1)
52
- echo -e "${GREEN}✔${NC} ${DIM}$((END - START))s ${SIZE}${NC}"
48
+ # Per-image best-effort: a transient registry failure for one image must not
49
+ # abort caching the others (this runs inside build-infra now). A guarded
50
+ # command does not trip `set -e`. The preload service skips a missing tarball
51
+ # gracefully — that module just pulls over the sim at deploy time.
52
+ if docker pull "$image" > /dev/null 2>&1 && docker save "$image" -o "$tarball"; then
53
+ END=$(date +%s)
54
+ SIZE=$(du -h "$tarball" | cut -f1)
55
+ echo -e "${GREEN}✔${NC} ${DIM}$((END - START))s ${SIZE}${NC}"
56
+ else
57
+ rm -f "$tarball"
58
+ echo -e "${YELLOW}✘ failed — will pull over the sim at deploy time${NC}"
59
+ fi
53
60
  done
54
61
 
55
62
  echo ""
@@ -0,0 +1,16 @@
1
+ $ORIGIN iamtheinternet.org.
2
+ $TTL 300
3
+
4
+ @ IN SOA ns1.iamtheinternet.org. admin.iamtheinternet.org. (
5
+ 2024020247 300 60 604800 300
6
+ )
7
+
8
+ ; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
9
+ ; the e2e simulation is to model real internet connectivity, where
10
+ ; private IPs are unreachable from outside. All public-facing names
11
+ ; resolve to the firewall's external IP (100.100.0.100), which DNATs
12
+ ; inbound to the right internal host.
13
+ @ IN NS ns1.iamtheinternet.org.
14
+ ns1 IN A 100.64.0.55
15
+ @ IN A 0.0.0.0
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
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.7.15",
3
+ "version": "0.7.17",
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",
@@ -19,7 +19,8 @@
19
19
  },
20
20
  "scripts": {
21
21
  "test": "bun test tests/",
22
- "test:completion": "bun test tests/completion"
22
+ "test:completion": "bun test tests/completion",
23
+ "test:integration": "bun test tests-integration/"
23
24
  },
24
25
  "files": [
25
26
  "src/",
@@ -33,7 +34,9 @@
33
34
  "npm-registry-server/tsconfig.json",
34
35
  "npm-registry-server/src/",
35
36
  "scripts/",
36
- "README.md"
37
+ "README.md",
38
+ "AGENTS.md",
39
+ "COVERAGE.md"
37
40
  ],
38
41
  "dependencies": {
39
42
  "@celilo/cli-display": "^0.1.9",
@@ -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
+ });