Repository navigation
Platform skeleton: bootstrap, proxy and local TLS #2
Description
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.testshows a placeholder with a valid certificate in a Windows browser;https://mail.rsl-commerce.testshows Mailpit;- every infrastructure service reports healthy;
make statusandmake doctorreport what's running.
Hypotheses to confirm in this slice:
- browsers honour the name constraint on the root;
- importing the CA into
CurrentUser\Rootfrom WSL needs no admin.
Scope.
- In:
compose.yamlwith 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 onmail., and a handshake-rejecting default server. - POSIX scripts
bootstrap,gen-env,ca,trust,hostsanddoctor. - Make targets
bootstrap,up,down,reset,logs,status,doctor,hostsandtrust. .env.example,.gitignoreand 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.testwith 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-2ignores only.agent/..githooks/setup:16-19holds the git-secrets patterns to extend..github/workflows/codeql.yml:27,30,36pins 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-commercelines withoutdesign., andCurrentUser\Rootholds 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.
doctorwarns 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-NoProfileand an absolutepowershell.exepath. - Portability:
- POSIX
sh, with nolocal,pipefail,sed -i,readlink -f,date -d,stat -cortimeoutoutside WSL-only code. - Make calls
sh ./scripts/x.sh. - openssl calls are LibreSSL-safe.
- The Makefile works with Make 3.81.
- POSIX
- Idempotence: a second
make bootstrapshows no dialog and no UAC prompt, and changes no file hash. - Hypothesis: a warm
make uptakes 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: "
fileis 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 anx-hardenedanchor. Every image is pinned by digest. Healthchecks usestart_interval. Thecerts/leafbind setscreate_host_path: false.compose.tools.yaml: shellcheck and hadolint, pinned so Dependabot can bump them, run withnetwork_mode: none.images/{mailpit,meilisearch}/Dockerfile: derived images, built withpull_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 variableproxy_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}.confandproxy/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 tocodeql.ymlanddependabot.yml. - Docs:
docs/adr/0001-platform-stack.mdandREADME.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 aTest-Pathcheck. - 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 withWriteAllBytes, then runsClear-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 hostsapplies them).
Steps
- 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 --scanblocks generated samples. - Estimate 30m · Basis: no baseline: judgement, 20m–45m
- Files:
- S2: CA (REQ-002, REQ-003).
ca.shplustests/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
- 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 upexits 0 with all 6 services healthy. - Estimate 50m · Basis: no baseline: judgement, 40m–1h20m
- 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
- S5: Trust (REQ-004, REQ-005, REQ-018).
trust.shanduntrust. 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
- S6: Hosts (REQ-006, REQ-007, REQ-005).
hosts.shplustests/hosts.test.shand 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
- qa: Windows and browsers (REQ-003, REQ-011).
Invoke-WebRequest, a negativeX509Chaincheck, and a throwaway bad leaf on:8443. The user checks Edge and Chrome.- Estimate 20m · Basis: no baseline: judgement, 15m–40m
- 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
- 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
- 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:
- the macOS CI job;
- REQ-018;
- 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 hostskeepshosts.rsl-commerce.bak. Deleting the marked block reverts the change. - Trust:
make untrustremoves the root. - Containers and data:
make resetremoves 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
- Every in-scope acceptance check passes and is in the journal (S1–S9, qa, rehearsal, valkey, ci)
- The repository's required checks pass (Lint, Smoke, macOS tests, Analyze on Platform skeleton: local TLS proxy and hardened infrastructure #3)
- The PR carrying
Closes #2is merged (Platform skeleton: local TLS proxy and hardened infrastructure #3,3bd75fd) - ADRs and docs the change needs are updated (ADR 0001, README)
- Follow-ups are open issues, listed under Links (all resolved)
- Time posted and the retro written
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
- Epic: Walking skeleton commerce#4 (Walking skeleton)
- Decisions this builds on: Choose the platform stack #1 (research, evidence, decisions P-D0 to P-D11) and Choose the tech stacks commerce#3 (DE-4, DE-5, DE-11, DE-12)
- PR: Platform skeleton: local TLS proxy and hardened infrastructure #3
Follow-ups (asked for by @ABilenduke, 2026-10-08):
- Done:
Lint,SmokeandmacOS testsare required checks in "Protect the default branch", next toAnalyze (actions)(D-11's follow-up). - Done: a real Linux bootstrap run in CI: Run the Linux bootstrap path in CI #4 (PR Run the Linux bootstrap path in CI #5, merged; closed).
- Done: the commerce ADRs (DE-2, DE-5) and the root README's 8 addresses and Valkey: Record the runtime and container ADRs and fix the README addresses commerce#5 (PR Record the runtime and container ADRs and list every local address commerce#6, merged; closed).
- Done: bump the platform pin in
commerce: Point platform at its walking skeleton commerce#7 and Update platform subproject to latest commit commerce#9 (merged). - Not doing, per @ABilenduke: a real macOS bootstrap run; moving Docker Desktop's disk image.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
ABilenduke commentedon Oct 8, 2026
Time: issue-2
Generated by
worklog renderfromtime.jsonl. Do not edit by hand.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
ABilenduke commentedon Oct 8, 2026
S1: Secrets and repo hygiene (REQ-001, REQ-015)
New files:
scripts/lib.sh: the host list in one place, OS detection, printers andvolume_exists.scripts/gen-env.sh: writes.env(mode 600) withrsldev_<hex>values forPOSTGRES_PASSWORD,RABBITMQ_PASSWORDandMEILI_MASTER_KEY, adds the optional Authorize.Net keys empty, and writessecrets/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:
main.git check-ignorematchescerts/only for a path inside it, because the pattern ends in/and the directory doesn't exist yet.Verification
sh .githooks/setup;sh scripts/gen-env.shthree times, then compare the SHA-256 of.envand the secret filels -ld .env secrets secrets/postgres_password-rw-------,drwxr-xr-x,-rw-r--r--rsldev_rsl-commerce_postgrestest volume, blankedPOSTGRES_PASSWORD, rangen-env.envrestored (identical undercmp)git secrets --scanon runtime-generated samplesPOSTGRES_PASSWORD, a barersldev_value in a URL and a filled Authorize.Net key exit 1 (blocked). Empty keys and${RABBITMQ_PASSWORD:?…}exit 0git check-ignore -qon.env,secrets/…,certs/ca/rootCA-key.pem,certs/leaf/key.pem,.tools/xDecisions and deviations: None.
Commit:
34aa67f· Status: Completeworkflow · claude-code · 2026-10-08 01:28
ABilenduke commentedon Oct 8, 2026
S2: Name-constrained CA (REQ-002, REQ-003)
scripts/ca.shcreates two certificates: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 onlyDNS:rsl-commerce.testand excludes all IPv4 and IPv6 addresses (the expanded IPv6 form). It is valid 825 days.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_serialand-sha256, so they also work with LibreSSL. When a running proxy exists, the script reloads it after reissuing.tests/ca.test.shruns everything in a temporary directory.Findings:
RSL_CERTS_DIR,RSL_ROOT_DAYSandRSL_LEAF_DAYSlet the tests exercise expiry without waiting.Verification
sh tests/ca.test.shcheckend 30dfails)sh tests/ca.test.sh, thendash tests/ca.test.shsh scripts/ca.shfor this machinersl-commerce dev CA AndrewsPC 20261008and the leaf. Modes:ca700, root key 600,leaf755, leaf key 644What the 44 checks cover:
rsl-commerce.test; IPv4 and IPv6 excluded; valid 824 to 825 days.openssl verifyrejectswww.example.com,localhost,127.0.0.1,::1, andrsl-commerce.testcombined with an IP.make trust, and the leaf moves to the new root.Decisions and deviations: None. The Windows
X509Chainand browser parts of REQ-003 are planned for QA.Commit:
4143fab· Status: Completeworkflow · claude-code · 2026-10-08 01:31
ABilenduke commentedon Oct 8, 2026
S3: The stack and the proxy (REQ-008, REQ-009, REQ-010)
compose.yamlruns the proxy, postgres, valkey, rabbitmq, mailpit and meilisearch. Every service uses onex-hardenedanchor: read-only,cap_drop ALL,no-new-privileges,initand a/tmptmpfs. 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 withpull_policy: build, changed only so their data directory belongs to 65534. Only127.0.0.1ports 80, 443 and 15672 are published.The proxy (
proxy/conf.d,proxy/snippets,proxy/html):ssl_reject_handshakefor 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.max-age=86400; includeSubDomainsfrom one snippet, plus a CSP, nosniff and no-referrer on the pages the proxy serves itself.add_header_inherit mergelets a location add headers without dropping its server's.proxy_pass, so the proxy starts without it. When it's down, its 502 and 504 become the 503 page.127.0.0.1:8081/healthz, inside the container only.Makefilecore targets:help(the default),up,down,resetandlogs.upfirst checks.env, the secret file and the leaf, and says "Run 'make bootstrap' first" when one is missing.resetasks unlessCONFIRM=yes.Findings:
add_header_inherit mergeis accepted at the http level on 1.30.5.wgetexists in the alpine-slim image./mailpit readyzworks withMP_ALLOWED_HOSTSset.${VAR:?}on every command,downincluded. A missing secret file doesn't stopconfigordown. Both were probed in a scratch project.make resetwould fail in the case bootstrap sends you to it: a credential missing while its volume exists, with.envincomplete.downandresetnow pass placeholder values, which they never use.Verification
docker compose config -q;docker compose run --rm --no-deps --entrypoint nginx proxy -tmake up(first run; the derived images built)healthy, 14 smake upagain (warm)curl --cacert certs/ca/rootCA.pem --resolve <host>:443:127.0.0.1for 3 hostsrsl-commerce.test200,mail.200,api.503, all HTTP/2, verified against the root. A first attempt named the wrong CA file (curl exit 77).docker compose ps127.0.0.180, 443 and 15672Decisions and deviations: The Makefile's stop commands pass placeholder credentials so
make resetworks with an incomplete.env(above). The deep checks for each host, service and hardening setting are in S4'smake smoke.Commit:
825b41b· Status: Completeworkflow · claude-code · 2026-10-08 01:39
ABilenduke commentedon Oct 8, 2026
S4: make smoke (REQ-009, REQ-010, REQ-012, REQ-013)
scripts/smoke.sh(viamake smoke) checks the running stack in four groups. Each check prints oneokorFAILline, and a failure lists every mismatch. It starts by confirming that all services are healthy, and otherwise says to runmake up.Requests go to
127.0.0.1withcurl --resolveand trust onlycerts/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; includeSubDomainsand its page:rsl-commerce.test;mail.;The placeholder and the 503 page are checked for their exact CSP. On every host,
http://<host>/x?y=1redirects with a 301 tohttps://<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):
postgreswith the generated password, and a wrong password gets "password authentication failed".valkey-cli -h valkey pinganswersPONG.quorum(the check creates it, then deletes it).mailpit sendmail -S mailpit:1025appears in/api/v1/searchthrough the proxy. APUTwith Mailpit's ownOriginreturns 200, one from another site returns 403, and the message is deleted again, always by its ID./healthreturns 200;/indexesreturns 401 without the key and 200 with it.Containers (REQ-009): for each container,
docker inspectshows a numeric non-root user,ReadonlyRootfs,CapDrop [ALL], noCapAdd,no-new-privileges,initand not privileged. The published ports are exactly127.0.0.180, 443 and 15672.Data (REQ-013): a row written to Postgres is still there after
make downandmake up, and the table is dropped afterwards.When Postgres or RabbitMQ refuses the password from
.env, the script says the volume predates.envand points tomake reset(the risk table's mitigation).env_getmoved fromgen-env.shtolib.sh, so the scripts read.envone way.gen-env.shstill keeps every value: its hashes were unchanged after a re-run.Findings:
smoke.sh. It does report earlier files, which S8'smake linthandles as planned:lib.sh: SC2034, the shared variables, which are used by the scripts that source it;ca.test.sh: SC2012 forlsreading a mode, and SC2016 for thesh -carguments. Both are infos, and both are intended.Verification
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 inspectshows all 6 hardened.make smoke(/bin/shis dash here)dash scripts/smoke.shsmoke_persistencetable, the queue returns 404, and nosmoke-messages remaindocker compose stop valkey, thenmake smokemake uprestored itsh scripts/gen-env.shafter theenv_getmovekeptfor all three keys;.envand the secret file hashes unchangedkoalaman/shellcheck:v0.11.0 -xon all scriptssmoke.shclean; the earlier findings aboveDecisions and deviations: The extra Mailpit 403 check (above).
Commit:
3bd5650· Status: Completeworkflow · claude-code · 2026-10-08 01:46
ABilenduke commentedon Oct 8, 2026
S5: make trust and make untrust (REQ-004, REQ-005, REQ-018)
scripts/trust.shhas three modes:trust(make trust),untrust(make untrust) andcheck, which status and doctor will use. Onlycerts/ca/rootCA.pemis ever trusted.CurrentUser\RootthroughImport-Certificate(P-D4), with no admin rights.Test-Pathconfirms the result afterwards.-EncodedCommand, and values reach it throughenvand WSLENV, never spliced into code. Thepowershellhelper is inlib.sh. Values containing spaces,$,'and"arrive intact.sudo security add-trusted-cert(D-9: no NSS).certutilexists, as in P-D4.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.lib.sh, shared withca.sh.Findings:
$ProgressPreference = 'SilentlyContinue'.Test-Path Cert:\CurrentUser\Root\test the store itself, which is true. Bothshand PowerShell now require 40 hex characters.Verification
CurrentUser\Root124,LocalMachine\Root115, none of ours; mkcert entryA3F73170…inCurrentUser\Roottrust.sh checkbefore trusting; untrust with nothing to remove…development2, not a CN-only lookalike; 0 live matcheslib.sh: an empty thumbprint, and values with special charactersmake trust: dialog shown (Yes clicked by mistake)CurrentUser125 with ours,LocalMachine115 withoutmake trustagainmake untrust, Yesmake trust, Nomake trust, YesDecisions 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 in42e1aa5· Status: Completeworkflow · claude-code · 2026-10-08 11:22
24 remaining items
Check AMQP, the messages API and Mailpit's WebSocket in smoke
Fail doctor when the containers can't read the checkout
Keep CodeQL's token off disk and exercise make on macOS
Record the review's changes in ADR 0001
Require a Valkey password and keep data services off the edge
Skip the S/MIME refusal where LibreSSL can't show it
ABilenduke commentedon Oct 8, 2026
CI on PR #3 (REQ-005, REQ-016)
1260827845df9fThe failure:
tests/ca.test.sh"rejects a leaf for signing email" on macOS. Its/usr/bin/opensslis LibreSSL 3.3.6, which ignores a CA'sextendedKeyUsagewhen checking-purpose smimesign.845df9f): the test prints the library it uses. On LibreSSL before 3.8, that one refusal reportsskipwith the reason; the root's own usage limit is still asserted.NotValidForUsage(journal rehearsal).The macOS log on
845df9f:# using LibreSSL 3.3.6;/bin/sh, andmake helpran (GNU Make 3.81);ca.test.sh: all checks passed(one skip, as above);hosts.test.sh: all checks passed.This closes:
/bin/sh.Status: CI green. Ready for review.
workflow · claude-code · 2026-10-08 12:19
Platform skeleton: local TLS proxy and hardened infrastructure (#3)
ABilenduke commentedon Oct 8, 2026
Retro
Delivered: REQ-001 to REQ-018. That is
make bootstrap,up,down,reset,logs,status,doctor,hosts,trustanduntrust, plus:X509Chain, Edge and Chrome;edgeanddatanetworks;smoke, CA and hosts tests;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'supdate-ca-trust, neither of which was required.Time: estimate 8h55m · active 1h11m · ratio 0.13 · appetite 6h (from the
timecomment)Went well:
CurrentUser\Rootimport needs no admin.Went badly:
worklog forecasthad no baseline, so the 6h appetite never constrained anything.-purpose smimesign. Local tests under busybox didn't reproduce macOS.ca.test.shcase passed without testing what it named (S2), so it had to be fixed on the first run.worklog forecasta baseline, so plans should quote it.--repo Reference-Systems-Lab/commerce-platformuntil their repository has historyAGENTS.md(#6)workflow · claude-code · 2026-10-08 15:02