Skip to content

Platform skeleton: bootstrap, proxy and local TLS #2

Closed

Description

@ABilenduke

Bootstrap the local environment and serve a placeholder at https://rsl-commerce.test through the proxy, using the locally trusted, name-constrained certificate. Part of the walking skeleton.

Status

Stage Track Next action Branch PR
done standard Done feat/2-platform-skeleton #3

Appetite: 6h active · Estimate: 8h55m (judgement) · Active so far: 1h11m (from the time comment)

Merged in #3 (3bd75fd); follow-ups #4, Reference-Systems-Lab/commerce#5 and Reference-Systems-Lab/commerce#7 merged too. See the retro comment.

Brief

Idea: "Brief the platform skeleton." This is step 1 of the walking skeleton (commerce#4): make bootstrap and make up bring up the proxy, local TLS and the infrastructure services, and serve a placeholder at https://rsl-commerce.test.

Problem and audience: Nothing in the platform runs yet. Every later step needs it: the backend in step 2 needs Postgres, Valkey, RabbitMQ, Mailpit and Meilisearch behind a working proxy. Several assumptions about local trust were never tested, because the spike touched no trust store or hosts file. The audience is contributors and anyone cloning the portfolio onto WSL2, macOS or Linux.

Outcome: On a clean WSL2 machine, make bootstrap followed by make up gives:

  • https://rsl-commerce.test shows a placeholder with a valid certificate in a Windows browser;
  • https://mail.rsl-commerce.test shows Mailpit;
  • every infrastructure service reports healthy;
  • make status and make doctor report what's running.

Hypotheses to confirm in this slice:

  • browsers honour the name constraint on the root;
  • importing the CA into CurrentUser\Root from WSL needs no admin.

Scope.

  • In:
    • compose.yaml with the nginx-unprivileged proxy, plus PostgreSQL 18, Valkey 9.1, RabbitMQ 4.3, Mailpit and Meilisearch. All hardened (DE-5) with healthchecks. Profiles as decided.
    • The derived Mailpit and Meilisearch images.
    • An nginx config per host: the placeholder on rsl-commerce.test, Mailpit on mail., and a handshake-rejecting default server.
    • POSIX scripts bootstrap, gen-env, ca, trust, hosts and doctor.
    • Make targets bootstrap, up, down, reset, logs, status, doctor, hosts and trust.
    • .env.example, .gitignore and git-secrets key patterns.
    • A CI lint job: docker compose config, shellcheck and hadolint, with actions SHA-pinned.
    • The platform ADR, and the platform README updated for eight hosts and Valkey.
  • Out:
    • Application services and app contract fragments.
    • seed, because it needs the backend.
    • Observability (otel-lgtm), the engineering docs site, and ecosystem E2E and contract tests.
    • The root and design-system README edits, which belong to their own repos.
  • Deferred:
    • Grype and SBOM jobs, until the stack runs images worth scanning.
    • Testing the macOS and Linux paths on real hosts.

Constraints:

  • The commands are bootstrap, up, down, reset, seed, logs and status (README.md:10-11).
  • The platform owns the proxy, TLS and routing (README.md:12).
  • Infrastructure is PostgreSQL, Redis, RabbitMQ, Mailpit and Meilisearch, with Valkey replacing Redis (README.md:13; commerce#3 DE-12).
  • Health checks cover every service (README.md:16).
  • One hosts line per address (README.md:32); there are eight hosts (DE-11).
  • Applications own their contract, and the platform only provisions (AGENTS.md:8-10).
  • Minimal manual steps; dev secrets only; every service meets a need (AGENTS.md:16-19).
  • Decisions are in commerce-platform#1:
    • P-D0b: WSL2, macOS and Linux;
    • P-D1: Compose ≥ 5, profiles, --wait;
    • P-D3: Make over POSIX scripts;
    • P-D4: openssl CA name-constrained to rsl-commerce.test with an 8-SAN leaf, trusted through OS tools; hosts printed, applied on request;
    • P-D5: gen-env, Compose secrets, git-secrets patterns;
    • P-D6: nginx-unprivileged and local HSTS;
    • P-D7: the service table.
  • Also commerce#3 DE-4 (CI security) and DE-5 (containers).

Known in code: None yet; the repo is docs-only at 5db9dab.

  • .gitignore:1-2 ignores only .agent/.
  • .githooks/setup:16-19 holds the git-secrets patterns to extend.
  • .github/workflows/codeql.yml:27,30,36 pins actions by tag; they need SHA pins.

Track: standard. The spike settled every outside question, and what's left is answerable from the repo and commerce-platform#1. Appetite: 6h active.

No research questions: the spike's research comment and its evidence are the reference.

Assumptions:

  • WSL2 is tested end to end now. The macOS and Linux branches are written and shellcheck-clean but not run (@ABilenduke, 2026-10-08).
  • The machine's state needs reconciling: the Windows hosts file has 7 rsl-commerce lines without design., and CurrentUser\Root holds an old mkcert leaf. Bootstrap adds what's missing and removes nothing it didn't create.
  • Docker Desktop's disk image may still sit on a nearly full C: drive. doctor warns about low free space; it doesn't fix it.

Spec

ID Requirement (source) Priority Acceptance
REQ-001 Generated dev secrets (P-D5) Must When make bootstrap runs, the platform shall write:
• .env (mode 600), with a random rsldev_<hex> value for every secret key in .env.example;
• secrets/postgres_password (directory 755, file 644).
Both are gitignored. It shall keep existing values on re-run (hashes unchanged) and never print a value.
If a credential's volume already exists, then it shall refuse to regenerate that credential and say to run make reset.
REQ-002 Name-constrained CA and leaf (P-D4) Must When bootstrap runs with no CA, the platform shall create two certificates.
The root:
• a unique subject (host and date);
• CA:TRUE, pathlen:0;
• critical nameConstraints: DNS permits only rsl-commerce.test, and IPv4 0.0.0.0/0.0.0.0 and IPv6 0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0 are excluded;
• 825 days' validity.
The leaf:
• ECDSA P-256;
• SANs exactly the 8 hosts;
• 397 days' validity;
• EKU serverAuth.
If the leaf is missing, expires within 30 days, has different SANs, fails openssl verify against the root, or doesn't match its key, then it shall reissue the leaf and keep the root.
If the root expires within 427 days, then it shall rotate the root (re-trust needed).
REQ-003 Constraint enforced (brief hypothesis) Must If a leaf for localhost, www.example.com or an IP address is signed by the root, then openssl verify shall reject it, and so shall a Windows X509Chain build (HasNotPermittedNameConstraint). Edge and Chrome shall refuse it at https://localhost:8443.
REQ-004 Trust on WSL2 (P-D4, brief) Must When make trust runs on WSL2, the platform shall import only the root into Windows CurrentUser\Root, behind the certificate dialog alone (no UAC prompt, no admin).
• It prints the thumbprint first.
• It skips the import when the thumbprint is already present.
• Afterwards CurrentUser\Root\<tp> exists and LocalMachine\Root\<tp> does not.
• The old mkcert leaf is untouched.
If the dialog is declined, then it shall exit non-zero with a clear message.
REQ-005 Portability: macOS and Linux (P-D0b, brief) Should The macOS and Linux trust and hosts paths exist and are shellcheck-clean. The CA and hosts-transform tests pass on a macos-latest runner with LibreSSL under /bin/sh.
REQ-006 Hosts check (P-D4) Must When bootstrap runs, the platform shall list the hosts missing from the hosts file the browser uses, and flag any host mapped to another IP as a conflict, without modifying the file.
REQ-007 Hosts apply (P-D4) Must When make hosts runs, the platform shall show the diff. After one elevation, it shall write a marked # BEGIN rsl-commerce / # END rsl-commerce block that holds only the missing hosts.
• It backs up the file once, to hosts.rsl-commerce.bak.
• Every byte outside the block stays identical, and CRLF is kept.
If the file changed between read and write (for example a Docker Desktop rewrite), then it shall abort without writing.
When nothing is missing, it shall make no prompt and no change.
REQ-008 Stack up (README.md:10-13) Must When make up runs after bootstrap, the platform shall start the proxy, postgres, valkey, rabbitmq, mailpit and meilisearch, and exit 0 only when all 6 are healthy.
If the certificates or .env are missing, then it shall fail with "run make bootstrap" and create no root-owned paths.
REQ-009 Hardening and exposure (DE-5, P-D7) Must Every container shall run:
• as a numeric non-root user;
• read-only;
• with cap_drop ALL, no-new-privileges and init;
• unprivileged.
Every image: and FROM shall be pinned by digest, and derived images are never pulled (pull_policy: build). Only 127.0.0.1 ports 80, 443 and 15672 are published.
The data services (postgres, valkey, rabbitmq, meilisearch) shall be on an internal data network that the proxy isn't on (D-13).
REQ-010 TLS and routing (P-D6, DE-11) Must All 8 hosts shall answer over HTTP/2, with HSTS exactly max-age=86400; includeSubDomains:
• https://rsl-commerce.test returns the placeholder (200) with its CSP;
• mail. returns Mailpit (200);
• the other 6 hosts return a 503 "not running yet" page.
If the SNI is unknown or missing, then the TLS handshake shall be rejected (curl exit 35).
http://<known>/x?y returns a 301 to https://<known>/x?y, and http://<unknown> is closed (curl exit 52).
REQ-011 Browser trust (brief outcome) Must When Edge or Chrome on Windows opens https://rsl-commerce.test and https://mail.rsl-commerce.test after bootstrap, make hosts and make up, the browser shall show them with no certificate warning. Invoke-WebRequest from Windows returns 200 for both.
REQ-012 Services usable (README.md:13, DE-12) Must Each service shall work over the network the backend will use:
• Postgres logs in over TCP (-h postgres) with the generated password, and a wrong password fails.
• valkey-cli -h valkey ping returns PONG with the generated VALKEY_PASSWORD and NOAUTH without it (D-13).
• A RabbitMQ queue declared without a type is quorum.
• A message sent over SMTP appears at https://mail…/api/v1/messages.
• A Mailpit write (PUT with Origin) returns 200.
• Meilisearch /health is available, and /indexes returns 401 without the key and 200 with it.
REQ-013 Down and reset (README.md:10-11) Must make down shall keep data: a row written to Postgres survives down then up. make reset shall confirm first (or take CONFIRM=yes), remove the volumes, and keep .env, secrets/ and certs/.
REQ-014 Status and doctor (brief) Must make status shall show service health, the certificate's days left, trust yes/no, and missing hosts.
make doctor shall check, without changing anything:
• docker and Compose ≥ 5;
• not running as root;
• ports 80, 443 and 15672 free or ours (the Windows side on WSL);
• disk free on Docker's data drive (warn below 30 GiB, fail below 5 GiB);
• XDG_RUNTIME_DIR (warn);
• the repo not under /mnt (warn);
• .env complete;
• the CA and leaf;
• trust;
• hosts.
It exits 1 only on a failure. doctor --preflight runs only the fatal prerequisite checks.
REQ-015 No secrets committed (AGENTS.md:18) Must .env, secrets/, certs/ and .tools/ are gitignored. git-secrets blocks a commit containing:
• a filled POSTGRES_PASSWORD, RABBITMQ_PASSWORD or MEILI_MASTER_KEY;
• an Authorize.Net key;
• any rsldev_<hex> value;
• a PEM key.
Empty values and ${…} references pass.
REQ-016 CI and updates (DE-4) Must On every PR, CI shall pass with contents: read, persist-credentials: false, a timeout and a concurrency group:
• lint: compose config, nginx -t, shellcheck, hadolint, a digest-pin check, and the CA and hosts tests;
• smoke: bootstrap --ci, up --wait and make smoke, with logs on failure and down -v always.
Every uses: is a 40-hex SHA with a # vX.Y.Z comment, and codeql.yml is re-pinned. Dependabot covers github-actions, docker-compose and docker (/images/*), with groups and a 7-day cooldown; nginx stays on the 1.30 stable line and Postgres on major 18.
REQ-017 Docs (AGENTS.md:37, brief) Must ADR 0001 records the platform stack and this slice's deviations:
• the file-sourced Postgres secret;
• the 0644 leaf key;
• IPv6 excluded;
• no NSS on macOS.
The README lists the 8 hosts and Valkey (Redis-compatible), and covers getting started, the commands, the manual steps per OS, the HSTS reset, and troubleshooting with doctor.
REQ-018 Untrust Could make untrust shall remove only roots with this platform's subject, never the mkcert leaf.

Quality requirements:

  • Security: the root key is 600 in certs/ca (700) and never mounted. Hosts and trust changes are hash-checked and backed up. Elevated code runs inline, with -NoProfile and an absolute powershell.exe path.
  • Portability:
    • POSIX sh, with no local, pipefail, sed -i, readlink -f, date -d, stat -c or timeout outside WSL-only code.
    • Make calls sh ./scripts/x.sh.
    • openssl calls are LibreSSL-safe.
    • The Makefile works with Make 3.81.
  • Idempotence: a second make bootstrap shows no dialog and no UAC prompt, and changes no file hash.
  • Hypothesis: a warm make up takes under 60 s here (measured, not a gate).

Non-goals:

  • apps, contract fragments, seed;
  • observability, the docs site, E2E and contract tests;
  • Grype and SBOM jobs;
  • real macOS and Linux bootstrap runs;
  • making the new CI jobs required checks (a follow-up that needs the user's OK);
  • WSL's own Linux trust store.

Plan

Approach

The repo is docs-only at 5db9dab: README.md:10-32 and AGENTS.md:8-19. The git hooks are .githooks/setup:16-19, and the CI is .github/workflows/codeql.yml:24-36 and .github/dependabot.yml:4-8.

The design reuses the spike's proven configs: the hardened service blocks, the derived images, the nginx TLS proxy, the name-constraint extensions, the proxy test script and the idempotent gen-env. A design review checked it against the Compose binaries, Mailpit's source, the upstream Dockerfiles and probes on this machine.

Two findings were re-verified by the lead and change the design:

  • Compose 5.5.1 refuses environment-sourced secrets in read-only services (error: "file is the sole supported option"), so the Postgres password becomes a file-sourced secret.
  • OpenSSL rejects the IPv6 name constraint IP:::/0, so the expanded form is used.

Proposed files:

  • compose.yaml: proxy, postgres, valkey, rabbitmq, mailpit and meilisearch, using an x-hardened anchor. Every image is pinned by digest. Healthchecks use start_interval. The certs/leaf bind sets create_host_path: false.
  • compose.tools.yaml: shellcheck and hadolint, pinned so Dependabot can bump them, run with network_mode: none.
  • images/{mailpit,meilisearch}/Dockerfile: derived images, built with pull_policy: build.
  • config/rabbitmq/20-commerce.conf: RabbitMQ settings.
  • proxy/conf.d/*:
    • add_header_inherit merge;
    • a default 443 server that rejects the handshake;
    • port 80: 301 to https for known hosts, 444 for anything else;
    • an internal health listener;
    • the placeholder on rsl-commerce.test;
    • mail. proxied through a variable proxy_pass, forwarding the Host header (Mailpit rejects a foreign Host and Origin);
    • a 503 for the 6 hosts that aren't running yet.
  • proxy/snippets/{hsts,proxy}.conf and proxy/html/*.
  • scripts/: lib.sh, gen-env, ca, trust, hosts, doctor, bootstrap, status, smoke.
  • tests/{ca,hosts}.test.sh.
  • Makefile.
  • CI: .github/workflows/ci.yml, plus updates to codeql.yml and dependabot.yml.
  • Docs: docs/adr/0001-platform-stack.md and README.md.

On WSL, Windows is reached through powershell.exe:

  • Values: passed with WSLENV, never interpolated into the PowerShell source.
  • Trust: an import into CurrentUser\Root, then a Test-Path check.
  • Hosts: staged through GetTempFileName, then written by an elevated -EncodedCommand. It compares the file's SHA-256 with the original and checks the staged file's SHA-256 before writing, keeps a one-time backup, writes with WriteAllBytes, then runs Clear-DnsClientCache.

Rejected alternatives:

  • env-sourced secrets (Compose refuses them);
  • mkcert (unmaintained, and its root is unconstrained);
  • Traefik labels (they need the Docker socket);
  • a prompt to apply hosts during bootstrap (P-D4 prints the lines; make hosts applies them).

Steps

  1. S1: Secrets and repo hygiene (REQ-001, REQ-015).
    • Files: .gitignore, .env.example, lib.sh, gen-env.sh (rsldev_ values, volume guard, secret file), and the git-secrets patterns in .githooks/setup.
    • Check: three runs give identical hashes, the modes are right, no value is printed, and git secrets --scan blocks generated samples.
    • Estimate 30m · Basis: no baseline: judgement, 20m–45m
  2. S2: CA (REQ-002, REQ-003).
    • ca.sh plus tests/ca.test.sh.
    • Check: the test covers the extensions, the good leaf, the localhost, foreign-DNS and IP leaves, and each reissue trigger.
    • Estimate 30m · Basis: no baseline: judgement, 20m–50m
  3. S3: Stack (REQ-008, REQ-009, REQ-010). Depends on S1 and S2.
    • The compose stack, derived images, RabbitMQ config, the nginx config, snippets and HTML, and the core Make targets.
    • Check: the first make up exits 0 with all 6 services healthy.
    • Estimate 50m · Basis: no baseline: judgement, 40m–1h20m
  4. S4: Smoke (REQ-010, REQ-012, REQ-013).
    • smoke.sh: host routing, TLS edge cases, the services, the Mailpit write path, hardening and ports, and persistence across down and up.
    • Estimate 30m · Basis: no baseline: judgement, 20m–45m
  5. S5: Trust (REQ-004, REQ-005, REQ-018).
    • trust.sh and untrust. The user clicks the certificate dialog.
    • Check: thumbprint present in CurrentUser and absent in LocalMachine, and the mkcert leaf is still there.
    • Estimate 20m · Basis: no baseline: judgement, 15m–40m
  6. S6: Hosts (REQ-006, REQ-007, REQ-005).
    • hosts.sh plus tests/hosts.test.sh and the WSL elevation. The user clicks UAC.
    • Check: fixture tests pass, the real file minus the block is identical under cmp, and a second run doesn't prompt.
    • Estimate 40m · Basis: no baseline: judgement, 30m–1h10m
  7. qa: Windows and browsers (REQ-003, REQ-011).
    • Invoke-WebRequest, a negative X509Chain check, and a throwaway bad leaf on :8443. The user checks Edge and Chrome.
    • Estimate 20m · Basis: no baseline: judgement, 15m–40m
  8. S7: Doctor, bootstrap and status (REQ-014).
    • doctor.sh --preflight, bootstrap.sh --ci, status.sh.
    • Check: they run on a healthy stack, on a clean clone, and with one hosts line removed.
    • Estimate 30m · Basis: no baseline: judgement, 20m–45m
  9. S8: CI (REQ-016, REQ-005).
    • compose.tools.yaml, make lint, ci.yml (lint, smoke, macOS tests), the CodeQL re-pin and Dependabot.
    • Check: the first CI run is green.
    • Estimate 45m · Basis: no baseline: judgement, 30m–1h30m
  10. S9: Docs (REQ-017).
  • ADR 0001 and the README.
  • Check: inspection.
  • Estimate 20m · Basis: no baseline: judgement, 15m–30m

Total:

  • Steps: 5h15m (no baseline: judgement, 4h–7h).
  • Clean rehearsal, review and PR: about 25m more, for roughly 5h40m against the 6h appetite.

worklog forecast reported no basis: fewer than 5 finished steps have estimates. If time passes the appetite, the cuts are, in order:

  1. the macOS CI job;
  2. REQ-018;
  3. the CI smoke job.

Tests

Requirement Test or check (planned)
REQ-001 Run gen-env.sh three times and compare hashes; check modes 600, 755 and 644; grep the output for values; check the volume guard refuses.
REQ-002 tests/ca.test.sh: inspect the extensions; each reissue trigger reissues.
REQ-003 tests/ca.test.sh with bad leaves. In QA, the X509Chain status, and the bad-leaf page refused in Edge and Chrome.
REQ-004, REQ-018 Check the thumbprint in CurrentUser\Root and LocalMachine\Root before, after, after a second run, and after untrust; the mkcert leaf is still present.
REQ-005 shellcheck, plus the macOS-runner job running the CA and hosts tests with LibreSSL under /bin/sh.
REQ-006, REQ-007 tests/hosts.test.sh covers this machine's layout, LF, CRLF, no trailing newline, a replaced block, a conflict, and a cmp-identical re-run. Then the real run: diff and cmp, and a second run doesn't prompt.
REQ-008, REQ-009, REQ-010, REQ-012, REQ-013 make up exit status, then make smoke: routing, curl exit codes 35 and 52, h2, HSTS, CSP, the service checks, the Mailpit PUT with Origin, docker inspect hardening and ports, and a row surviving down and up.
REQ-011 Invoke-WebRequest from Windows, and the user checks Edge and Chrome.
REQ-014 make status; make doctor passing on a healthy stack and failing with one hosts line removed; --preflight on a clean clone.
REQ-015 git check-ignore, and git secrets --scan on samples generated at test time.
REQ-016 The PR's checks (lint, smoke, macOS tests, Analyze) all pass; a grep shows every uses: is a 40-hex SHA.
REQ-017 Inspection.

Risks

Risk (pre-mortem: "it failed because…") Likelihood Impact Mitigation
A UAC or certificate dialog is declined or hangs Medium Low Check the outcome with Test-Path and the exit code; give a clear message; don't retry.
The hosts file is corrupted, or races with Docker Desktop's managed block Low High A fixture-tested pure transform, SHA-256 compare-and-swap, a one-time backup, a no-op when nothing changed, and the diff printed first.
Spoofing: the trusted root intercepts other sites Low High Name constraints with IPs excluded; the root key at 600 and never mounted; untrust; a unique subject.
Spoofing: a client ignores name constraints Medium Medium The QA negative check with CryptoAPI, Edge and Chrome. Any client that fails is recorded in the ADR as one where the root key could act as a man-in-the-middle key.
Information disclosure: the 0644 leaf key and secret file Low Low Accepted for dev and recorded in the ADR: name-constrained, short-lived, and in gitignored directories on a single-user machine.
Tampering or elevation of privilege through the elevated hosts write Low High The elevated code runs inline from -EncodedCommand with -NoProfile and an absolute path; it writes only the fixed hosts path, and verifies the staged file's hash first.
Credentials baked into volumes on first boot Medium Low The gen-env volume guard; the smoke test hints at make reset.
Dependabot drifts nginx to mainline or Postgres to the next major Medium Medium Ignore rules.
C: is 97% full and the first pull is about 1 GB Medium Medium doctor warns; moving Docker's disk is the user's action.

Rollout and rollback

The change reaches contributors as a new make workflow in this repo. Nothing in other repos changes, and there is no data migration.

On this machine:

  • Hosts: make hosts keeps hosts.rsl-commerce.bak. Deleting the marked block reverts the change.
  • Trust: make untrust removes the root.
  • Containers and data: make reset removes the containers and volumes.

In the repo: rollback means reverting the PR.

Definition of Ready

  • Every Must has acceptance and a step that names it
  • No blocking decision is Open
  • Every step has an estimate and a basis, and the total fits the appetite (≈5h40m with the rehearsal and PR, against 6h)
  • Approved by @ABilenduke on 2026-10-08

Definition of Done

Revisions

Decisions

ID Decision Kind Source
D-1 The proxy serves the placeholder on rsl-commerce.test until the storefront exists. The 6 hosts with no app yet return a 503 "not running yet" page. Default Plan, 2026-10-08
D-2 The leaf key and the Postgres secret file are mode 0644, so non-root containers can read the bind mounts. The root key is 0600, in certs/ca (700), and is never mounted. Default Plan; Compose ignores mode on file secrets
D-3 The root's name constraints exclude every IPv4 and IPv6 range, using the expanded IPv6 form. Default Design review; OpenSSL 3.0.2 rejects ::/0 (verified)
D-4 The root lasts 825 days and the leaf 397. The leaf is reissued automatically; the root rotates when fewer than 427 days remain. Default Plan
D-5 The Postgres password is a file-sourced Compose secret, which still meets P-D5. Default Compose 5.5.1 refuses env-sourced secrets in read-only services (verified)
D-6 Mailpit is reached through the proxy with the original Host header, and MP_ALLOWED_HOSTS=mail.rsl-commerce.test,mailpit. Default Mailpit v1.31.4 source (design review)
D-7 Published ports are only 127.0.0.1 80, 443 and 15672. Confirmed commerce-platform#1 P-D7
D-8 seed arrives with the backend. Bootstrap prints the hosts lines, and make hosts applies them. Confirmed Brief; commerce-platform#1 P-D4
D-9 On WSL2, only the Windows store is trusted. On macOS, only the System keychain, because Firefox reads enterprise roots there. That deviates from P-D4's NSS step and is recorded in the ADR. Default Design review
D-10 CI uses the runner's Compose 5.1.3, which meets ≥ 5, so nothing is downloaded. Default ubuntu-26.04 runner image README (design review)
D-11 The lint tools are pinned in compose.tools.yaml. The new CI jobs are not made required checks in this PR. Default Plan; changing the ruleset needs the user's OK
D-12 The plan is approved. Confirmed @ABilenduke, 2026-10-08 (plan approval)
D-13 Valkey requires a generated VALKEY_PASSWORD, read from a secret file, not a command line. The services split into an edge network (the proxy and Mailpit's UI) and an internal data network (postgres, valkey, rabbitmq, meilisearch and Mailpit's SMTP). RabbitMQ also joins rabbitmq-ui so its management port can be published. Changes the spike's P-D7 (Valkey reachable only inside one shared network) and REQ-009 and REQ-012. Confirmed @ABilenduke, 2026-10-08 (review finding; "Fix in this PR")

Links

Follow-ups (asked for by @ABilenduke, 2026-10-08):

Activity

  1. added
    track:standardClear outcome: brief, plan, execute, review
    and removed on Oct 8, 2026
  2. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    Time: issue-2

    Generated by worklog render from time.jsonl. Do not edit by hand.

    Step Estimate Started Finished Wall Active Sessions
    brief 30m 2026-10-08 00:29 2026-10-08 00:31 2m 2m 3da8d26d (claude-code)
    plan 30m 2026-10-08 01:25 2026-10-08 01:26 1m 1m 3da8d26d (claude-code)
    S1 30m 2026-10-08 01:27 2026-10-08 01:28 1m 1m 3da8d26d (claude-code)
    S2 30m 2026-10-08 01:29 2026-10-08 01:31 2m 2m 3da8d26d (claude-code)
    S3 50m 2026-10-08 01:31 2026-10-08 01:39 8m 8m 3da8d26d (claude-code)
    S4 30m 2026-10-08 01:42 2026-10-08 01:46 5m 5m 3da8d26d (claude-code)
    S5 20m 2026-10-08 01:48 2026-10-08 01:52 4m 4m 3da8d26d (claude-code)
    S6 40m 2026-10-08 01:52 2026-10-08 02:01 9m 9m 3da8d26d (claude-code)
    qa 20m 2026-10-08 11:17 2026-10-08 11:22 5m 5m 3da8d26d (claude-code)
    S7 30m 2026-10-08 11:23 2026-10-08 11:26 3m 3m 3da8d26d (claude-code)
    S8 45m 2026-10-08 11:26 2026-10-08 11:31 5m 5m 3da8d26d (claude-code)
    S9 20m 2026-10-08 11:31 2026-10-08 11:33 2m 2m 3da8d26d (claude-code)
    feedback 1h 30m 2026-10-08 11:44 2026-10-08 11:52 9m 9m 3da8d26d (claude-code)
    rehearsal 25m 2026-10-08 11:53 2026-10-08 12:04 11m 11m 3da8d26d (claude-code)
    valkey 45m 2026-10-08 12:06 2026-10-08 12:10 4m 4m 3da8d26d (claude-code)
    review 2026-10-08 14:57 2026-10-08 14:58 0m 0m 96f0480c (claude-code)
    Total 8h 55m 1h 11m 1h 11m

    Active time counts agent turns plus up to 5 minutes of each wait for a person, and no single gap
    counts more than 30 minutes. — means nothing was recorded.

    workflow · claude-code · 2026-10-08 14:58

  3. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S1: Secrets and repo hygiene (REQ-001, REQ-015)

    New files:

    • scripts/lib.sh: the host list in one place, OS detection, printers and volume_exists.
    • scripts/gen-env.sh: writes .env (mode 600) with rsldev_<hex> values for POSTGRES_PASSWORD, RABBITMQ_PASSWORD and MEILI_MASTER_KEY, adds the optional Authorize.Net keys empty, and writes secrets/postgres_password (directory 755, file 644) for the file-sourced Compose secret (D-5).
    • .env.example: lists the keys.

    Changed:

    • .gitignore: ignores .env, secrets/, certs/ and .tools/.
    • .githooks/setup: three new git-secrets patterns.
    • .githooks/require-git-secrets.sh: gains a shellcheck directive.

    Findings:

    • Baseline: the repo has no tests or build. CodeQL (actions) is green on main.
    • Ignore check: git check-ignore matches certs/ only for a path inside it, because the pattern ends in / and the directory doesn't exist yet.

    Verification

    Iteration Checks run Result Fix
    1 sh .githooks/setup; sh scripts/gen-env.sh three times, then compare the SHA-256 of .env and the secret file Generated on run 1, kept on runs 2 and 3; hashes identical None
    1 ls -ld .env secrets secrets/postgres_password -rw-------, drwxr-xr-x, -rw-r--r-- None
    1 Output of run 3 grepped for rsldev_ 0 matches: no value printed None
    1 Volume guard: created a rsl-commerce_postgres test volume, blanked POSTGRES_PASSWORD, ran gen-env Exit 1: "POSTGRES_PASSWORD is empty but the rsl-commerce_postgres volume still holds the old one. Run 'make reset'…". Test volume removed and .env restored (identical under cmp) None
    1 git secrets --scan on runtime-generated samples Filled POSTGRES_PASSWORD, a bare rsldev_ value in a URL and a filled Authorize.Net key exit 1 (blocked). Empty keys and ${RABBITMQ_PASSWORD:?…} exit 0 None
    1 git check-ignore -q on .env, secrets/…, certs/ca/rootCA-key.pem, certs/leaf/key.pem, .tools/x All ignored None

    Decisions and deviations: None.

    Commit: 34aa67f · Status: Complete

    workflow · claude-code · 2026-10-08 01:28

  4. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S2: Name-constrained CA (REQ-002, REQ-003)

    scripts/ca.sh creates two certificates:

    • The root (certs/ca, 700; key 600, never mounted). It has a unique subject (O = rsl-commerce local development, CN = rsl-commerce dev CA <host> <date>), CA:TRUE, pathlen:0, and critical name constraints: it permits only DNS:rsl-commerce.test and excludes all IPv4 and IPv6 addresses (the expanded IPv6 form). It is valid 825 days.
    • The leaf (certs/leaf, 755; key 644 per D-2): ECDSA P-256 for the 8 hosts, with EKU serverAuth, valid 397 days.

    The openssl calls use config files, -set_serial and -sha256, so they also work with LibreSSL. When a running proxy exists, the script reloads it after reissuing.

    tests/ca.test.sh runs everything in a temporary directory.

    Findings:

    • The script prints the root's subject on creation; untrust will match on it (REQ-018).
    • Test hooks RSL_CERTS_DIR, RSL_ROOT_DAYS and RSL_LEAF_DAYS let the tests exercise expiry without waiting.

    Verification

    Iteration Checks run Result Fix
    1 sh tests/ca.test.sh 43 of 44 passed. "Certificate expiring within 30 days" failed because the test never produced a short leaf: a valid leaf already existed, so the 10-day run kept it. Test fix: remove the leaf before the 10-day run, and assert the setup (checkend 30d fails)
    2 sh tests/ca.test.sh, then dash tests/ca.test.sh All 44 checks passed under both None
    2 sh scripts/ca.sh for this machine Created the root rsl-commerce dev CA AndrewsPC 20261008 and the leaf. Modes: ca 700, root key 600, leaf 755, leaf key 644 None

    What the 44 checks cover:

    • Root: pathlen 0; signs only certificates and CRLs; critical name constraints; DNS permits only rsl-commerce.test; IPv4 and IPv6 excluded; valid 824 to 825 days.
    • Leaf: exactly the 8 SANs; serverAuth; not a CA; verifies against the root; valid at most 397 days.
    • Refusals: openssl verify rejects www.example.com, localhost, 127.0.0.1, ::1, and rsl-commerce.test combined with an IP.
    • Modes and idempotence: the modes above, and a second run changes no hash.
    • Reissues: a missing key, a mismatched key, a certificate for other hosts, and a leaf expiring within 30 days. Each reissue keeps the root and verifies.
    • Root rotation: a 400-day root is replaced, the user is told to run make trust, and the leaf moves to the new root.

    Decisions and deviations: None. The Windows X509Chain and browser parts of REQ-003 are planned for QA.

    Commit: 4143fab · Status: Complete

    workflow · claude-code · 2026-10-08 01:31

  5. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S3: The stack and the proxy (REQ-008, REQ-009, REQ-010)

    compose.yaml runs the proxy, postgres, valkey, rabbitmq, mailpit and meilisearch. Every service uses one x-hardened anchor: read-only, cap_drop ALL, no-new-privileges, init and a /tmp tmpfs. Each runs as a numeric user (proxy 101, postgres, valkey and rabbitmq 999, mailpit and meilisearch 65534). Every image is pinned by digest. Mailpit and Meilisearch are built from pinned bases with pull_policy: build, changed only so their data directory belongs to 65534. Only 127.0.0.1 ports 80, 443 and 15672 are published.

    The proxy (proxy/conf.d, proxy/snippets, proxy/html):

    • TLS: 1.2 and 1.3, HTTP/2 on every 443 server, and ssl_reject_handshake for any other name. Plain HTTP to an unknown name gets 444; to a known host, a 301 to HTTPS that keeps the path and query.
    • Headers: HSTS max-age=86400; includeSubDomains from one snippet, plus a CSP, nosniff and no-referrer on the pages the proxy serves itself. add_header_inherit merge lets a location add headers without dropping its server's.
    • Mailpit: proxied with the original Host (D-6) through a variable proxy_pass, so the proxy starts without it. When it's down, its 502 and 504 become the 503 page.
    • Healthcheck: 127.0.0.1:8081/healthz, inside the container only.

    Makefile core targets: help (the default), up, down, reset and logs. up first checks .env, the secret file and the leaf, and says "Run 'make bootstrap' first" when one is missing. reset asks unless CONFIRM=yes.

    Findings:

    • The open questions from the plan all came out as hoped:
      • add_header_inherit merge is accepted at the http level on 1.30.5.
      • busybox wget exists in the alpine-slim image.
      • /mailpit readyz works with MP_ALLOWED_HOSTS set.
      • Postgres initialised from the 644 file secret as uid 999. The login check itself is in S4.
    • Compose interpolates ${VAR:?} on every command, down included. A missing secret file doesn't stop config or down. Both were probed in a scratch project.
    • So make reset would fail in the case bootstrap sends you to it: a credential missing while its volume exists, with .env incomplete. down and reset now pass placeholder values, which they never use.

    Verification

    Iteration Checks run Result Fix
    1 docker compose config -q; docker compose run --rm --no-deps --entrypoint nginx proxy -t Config valid; nginx syntax ok None
    1 make up (first run; the derived images built) Exit 0, all 6 healthy, 14 s None
    1 make up again (warm) Exit 0 in 2 s (the hypothesis says under 60 s) None
    1 curl --cacert certs/ca/rootCA.pem --resolve <host>:443:127.0.0.1 for 3 hosts rsl-commerce.test 200, mail. 200, api. 503, all HTTP/2, verified against the root. A first attempt named the wrong CA file (curl exit 77). The check's path
    1 docker compose ps Published ports exactly 127.0.0.1 80, 443 and 15672 None

    Decisions and deviations: The Makefile's stop commands pass placeholder credentials so make reset works with an incomplete .env (above). The deep checks for each host, service and hardening setting are in S4's make smoke.

    Commit: 825b41b · Status: Complete

    workflow · claude-code · 2026-10-08 01:39

  6. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S4: make smoke (REQ-009, REQ-010, REQ-012, REQ-013)

    scripts/smoke.sh (via make smoke) checks the running stack in four groups. Each check prints one ok or FAIL line, and a failure lists every mismatch. It starts by confirming that all services are healthy, and otherwise says to run make up.

    Requests go to 127.0.0.1 with curl --resolve and trust only certs/ca/rootCA.pem, so the script needs neither the hosts file nor a trust store, and the same script runs in CI. Passwords never reach a command line: the Postgres password is read inside the container from the secret, the RabbitMQ password goes to curl on stdin (-K -), and Meilisearch's key goes in as a header on stdin (-H @-).

    • Addresses (REQ-010): each of the 8 hosts returns its status over HTTP/2, with HSTS exactly max-age=86400; includeSubDomains and its page:

      • the placeholder on rsl-commerce.test;
      • Mailpit on mail.;
      • the 503 page on the other 6.

      The placeholder and the 503 page are checked for their exact CSP. On every host, http://<host>/x?y=1 redirects with a 301 to https://<host>/x?y=1. An unknown name and a request without SNI both get curl exit 35, with -k, so the refusal is the handshake and not the certificate. Plain HTTP to an unknown name gets exit 52.

    • Services (REQ-012):

      • Postgres logs in over TCP at postgres with the generated password, and a wrong password gets "password authentication failed".
      • valkey-cli -h valkey ping answers PONG.
      • A RabbitMQ queue declared without a type is quorum (the check creates it, then deletes it).
      • A message sent with mailpit sendmail -S mailpit:1025 appears in /api/v1/search through the proxy. A PUT with Mailpit's own Origin returns 200, one from another site returns 403, and the message is deleted again, always by its ID.
      • Meilisearch /health returns 200; /indexes returns 401 without the key and 200 with it.
    • Containers (REQ-009): for each container, docker inspect shows a numeric non-root user, ReadonlyRootfs, CapDrop [ALL], no CapAdd, no-new-privileges, init and not privileged. The published ports are exactly 127.0.0.1 80, 443 and 15672.

    • Data (REQ-013): a row written to Postgres is still there after make down and make up, and the table is dropped afterwards.

    When Postgres or RabbitMQ refuses the password from .env, the script says the volume predates .env and points to make reset (the risk table's mitigation).

    env_get moved from gen-env.sh to lib.sh, so the scripts read .env one way. gen-env.sh still keeps every value: its hashes were unchanged after a re-run.

    Findings:

    • The negative Mailpit check was added beyond the plan's list. A 403 for another origin shows that the 200 comes from Mailpit's origin check accepting the forwarded Host, not from no check at all.
    • shellcheck v0.11.0 reports nothing for smoke.sh. It does report earlier files, which S8's make lint handles as planned:
      • lib.sh: SC2034, the shared variables, which are used by the scripts that source it;
      • ca.test.sh: SC2012 for ls reading a mode, and SC2016 for the sh -c arguments. Both are infos, and both are intended.

    Verification

    Iteration Checks run Result Fix
    1 Probes of each API before writing the checks PUT queue 201, again 204; queue type quorum; RabbitMQ with a wrong password 401. Mailpit search returns the ID; PUT with our Origin 200, a foreign one 403; DELETE 200. Meilisearch 200/401/200. docker inspect shows all 6 hardened. None
    1 make smoke (/bin/sh is dash here) All 25 checks passed in 13 s, including the down/up None
    1 dash scripts/smoke.sh Exit 0 None
    1 After the run No smoke_persistence table, the queue returns 404, and no smoke- messages remain None
    1 docker compose stop valkey, then make smoke "5 of 6 services are healthy. Run 'make up' first.", exit non-zero; make up restored it None
    1 sh scripts/gen-env.sh after the env_get move kept for all three keys; .env and the secret file hashes unchanged None
    1 koalaman/shellcheck:v0.11.0 -x on all scripts smoke.sh clean; the earlier findings above Deferred to S8 as planned

    Decisions and deviations: The extra Mailpit 403 check (above).

    Commit: 3bd5650 · Status: Complete

    workflow · claude-code · 2026-10-08 01:46

  7. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S5: make trust and make untrust (REQ-004, REQ-005, REQ-018)

    scripts/trust.sh has three modes: trust (make trust), untrust (make untrust) and check, which status and doctor will use. Only certs/ca/rootCA.pem is ever trusted.

    • WSL2: Windows CurrentUser\Root through Import-Certificate (P-D4), with no admin rights.
      • It prints the subject and the SHA-1 thumbprint in the dialog's 8-character groups, and skips the import when that thumbprint is already present.
      • In PowerShell, the bytes are checked against the shown thumbprint before import, and Test-Path confirms the result afterwards.
      • The scripts reach PowerShell as -EncodedCommand, and values reach it through env and WSLENV, never spliced into code. The powershell helper is in lib.sh. Values containing spaces, $, ' and " arrive intact.
    • macOS: the System keychain via sudo security add-trusted-cert (D-9: no NSS).
    • Linux: the system store (Debian-family and Fedora-family anchors), plus Chrome's and Firefox's NSS databases (snap included) when certutil exists, as in P-D4.
    • Untrust: on Windows it removes only roots whose subject has O=rsl-commerce local development, an exact RDN match. On macOS it matches the CN prefix; on Linux, the anchor file and the NSS nicknames with our prefix.
    • The root's O and CN prefix moved to lib.sh, shared with ca.sh.

    Findings:

    • Windows PowerShell writes CLIXML progress records into redirected output, so every script sets $ProgressPreference = 'SilentlyContinue'.
    • An empty thumbprint makes Test-Path Cert:\CurrentUser\Root\ test the store itself, which is true. Both sh and PowerShell now require 40 hex characters.

    Verification

    Iteration Checks run Result Fix
    1 Baseline, read-only CurrentUser\Root 124, LocalMachine\Root 115, none of ours; mkcert entry A3F73170… in CurrentUser\Root None
    1 shellcheck; trust.sh check before trusting; untrust with nothing to remove Clean; exit 1; "no rsl-commerce root to remove" None
    1 Untrust's match on synthetic subjects Matches ours (alone or with C=), not mkcert's, not …development2, not a CN-only lookalike; 0 live matches None
    2 After moving the helper to lib.sh: an empty thumbprint, and values with special characters "no"; values intact Added the guard
    3 (QA window) make trust: dialog shown (Yes clicked by mistake) Added; CurrentUser 125 with ours, LocalMachine 115 without None
    3 make trust again "already trusts it", no dialog None
    3 make untrust, Yes Removed ours only; 124, the mkcert entry still present None
    3 make trust, No "Windows didn't add it (The operation was canceled by the user…)", exit non-zero None
    3 make trust, Yes Trusted; 125 / 115; mkcert present None

    Decisions and deviations: The decline test ran after untrust instead of first, because the first dialog was accepted by mistake. Every REQ-004 and REQ-018 path still ran. macOS and Linux are written and shellcheck-clean, not run (planned).

    Commits: 8e303f8; helper move and guard in 42e1aa5 · Status: Complete

    workflow · claude-code · 2026-10-08 11:22

  8. 24 remaining items

  9. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    CI on PR #3 (REQ-005, REQ-016)

    Run Lint Smoke macOS tests Analyze (actions) Dependabot config
    1260827 pass (10 s) pass (58 s) fail: 1 check pass pass
    845df9f pass (12 s) pass (1 m 9 s) pass (12 s) pass —

    The failure: tests/ca.test.sh "rejects a leaf for signing email" on macOS. Its /usr/bin/openssl is LibreSSL 3.3.6, which ignores a CA's extendedKeyUsage when checking -purpose smimesign.

    • Reproduced locally: LibreSSL 3.5.3 accepts the leaf; LibreSSL 3.8.2 and 4.1.2 and OpenSSL 3 refuse it.
    • Fix (845df9f): the test prints the library it uses. On LibreSSL before 3.8, that one refusal reports skip with the reason; the root's own usage limit is still asserted.
    • This is the verifier's age, not the certificate. Windows CryptoAPI refuses such leaves with NotValidForUsage (journal rehearsal).

    The macOS log on 845df9f:

    • # using LibreSSL 3.3.6;
    • every script parsed with /bin/sh, and make help ran (GNU Make 3.81);
    • ca.test.sh: all checks passed (one skip, as above);
    • hosts.test.sh: all checks passed.

    This closes:

    • REQ-005: the CA and hosts tests under macOS LibreSSL and /bin/sh.
    • REQ-016: lint and smoke pass on the runner's Compose; S8's "first CI run" deviation is resolved here.

    Status: CI green. Ready for review.

    workflow · claude-code · 2026-10-08 12:19

  10. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    Retro

    Delivered: REQ-001 to REQ-018. That is make bootstrap, up, down, reset, logs, status, doctor, hosts, trust and untrust, plus:

    • a name-constrained CA, proven against OpenSSL, LibreSSL, Windows X509Chain, Edge and Chrome;
    • the hardened, digest-pinned stack of 6 services, split into edge and data networks;
    • all 8 hosts over HTTP/2 with HSTS;
    • the smoke, CA and hosts tests;
    • CI (Lint, Smoke, macOS tests) with SHA-pinned actions and Dependabot;
    • ADR 0001 and the README.

    D-13 (a Valkey password and separate networks) was added during review. Follow-ups: the required checks (done), a Linux bootstrap run in CI (#4, closed), the system ADRs and README (Reference-Systems-Lab/commerce#5, closed) and the platform pin (Reference-Systems-Lab/commerce#7, merged).

    Not delivered: a real macOS bootstrap run, and moving Docker Desktop's disk image off C: (both dropped by @ABilenduke). Also the UAC-decline message of make hosts (S6) and Fedora's update-ca-trust, neither of which was required.

    Time: estimate 8h55m · active 1h11m · ratio 0.13 · appetite 6h (from the time comment)

    Went well:

    • The brief's two hypotheses held on this machine, with evidence: browsers and CryptoAPI honour the name constraint, and a CurrentUser\Root import needs no admin.
    • The four-reviewer adversarial pass found 35 issues. That includes the unauthenticated Valkey on a network shared with the proxy, which the spike had accepted.
    • The rehearsal from nothing and green CI on every job left nothing to discover after merge.

    Went badly:

    • Every estimate was judgement and ran about 7× over active time. worklog forecast had no baseline, so the 6h appetite never constrained anything.
    • The first macOS CI run failed: LibreSSL ignores a CA's EKU for -purpose smimesign. Local tests under busybox didn't reproduce macOS.
    • A ca.test.sh case passed without testing what it named (S2), so it had to be fixed on the first run.
    • The certificate dialog was accepted by mistake, so the decline test ran out of order (S5).
    Lesson Where it goes
    Judgement estimates ran about 7× over active time. This issue now gives worklog forecast a baseline, so plans should quote it. Reference-Systems-Lab/commerce#4 D-1: steps quote the forecast, with --repo Reference-Systems-Lab/commerce-platform until their repository has history
    An adversarial multi-reviewer pass and a from-nothing rehearsal before the PR caught what the step checks missed Platform AGENTS.md (#6)
    Spikes should check authentication for every data service, not only network reach Nowhere: recorded as D-13 and in ADR 0001

    workflow · claude-code · 2026-10-08 15:02

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions