# react-ui-registry-deployment | Field | Value | |---|---| | Change ID | `react-ui-registry-deployment` | | Category | ops | | Target path | `ops/registry/` | | Status | PROPOSED | | Date | 2026-05-05 | | Prereq (SHIPPED) | `enterprise-react-i18n` — archived at `openspec/archive/2026-05-05-enterprise-react-i18n/`; capability spec at `openspec/specs/react-i18n/spec.md`. The registry hosts the source-eject manifests for `@suaveplan/react-ui` components, every one of which renders user-facing strings via `TranslationKey`-typed props the consumer resolves through the upstream `TranslationProvider`. The registry transform never strips i18n imports — source-ejected components retain the i18n integration. | | Prereq (in-flight) | `enterprise-react-ui-v2` — owns the registry source. The Caddy + Docker setup in this change just serves the JSON manifests + the Storybook static; it depends on the package shipping its `scripts/build-registry.ts` (Wave 2) and per-component manifests (Wave 3a-3h). | ## Why `@suaveplan/react-ui` is a hybrid runtime library + shadcn-compatible source-eject registry. The runtime library (`bun add @suaveplan/react-ui`) works in isolation, but the source-eject path (`npx shadcn@latest add @suaveplan/`) requires a public HTTPS endpoint serving the per-component registry JSON. Without that endpoint, external consumers cannot ingest components directly into their own `src/components/ui/` trees, and the entire shadcn-compatible half of the package is functionally inert outside this monorepo. The user has already provisioned `ui.suaveplan.com` on OVH (DNS A record points to the OVH host; `ssh root@ovh` reaches it). This change scaffolds the Docker + Caddy deployment that: 1. serves the registry JSON at `https://ui.suaveplan.com/r/*.json` so consumers can run `npx shadcn@latest add @suaveplan/` against a real HTTPS URL, 2. serves the Storybook static build at `https://ui.suaveplan.com/storybook/` under the same domain so prospective consumers can preview components before adding them, 3. wires a GitHub Actions deploy pipeline that rebuilds both artifacts on every push to `genesis` affecting the relevant sources and rsyncs them to OVH. This is the §3.5 + §10.3 deliverable from the master cross-cutting design at `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md`. ## Current-state audit - `ops/registry/` does not exist on the genesis branch. - `ui.suaveplan.com` resolves to the OVH host but no service responds on 80 or 443 yet (TLS will be issued on first `caddy` boot). - `packages/react/react-ui/scripts/build-registry.ts` does not exist yet — registry generation is a deliverable of this change because the deploy pipeline cannot ship until something can produce the artifact it ships. - No `.github/workflows/registry-deploy.yml` exists. - No prior ops-tier change has landed on genesis; `openspec/changes/` has 100+ entries but they are all package-tier. This change establishes the convention that ops-tier changes live in `openspec/changes/` alongside package changes and target a path outside `packages/`. ## Architecture decisions 1. **Static-only Caddy serving for v1.** The registry is a tree of JSON files; there is no runtime needed. A `caddy:2-alpine` image serves the files directly with `file_server` and `encode gzip zstd`. Live preview pages requiring a Bun runtime are explicitly Phase 2 and not in this change. 2. **Single domain, two paths.** `ui.suaveplan.com/r/*` for the registry; `ui.suaveplan.com/storybook/*` for hosted Storybook. Both share the same Caddyfile, the same TLS cert, and the same `/srv/registry/` filesystem root on the OVH host. Subdomains (`registry.suaveplan.com`, `storybook.suaveplan.com`) were considered and rejected — one cert, one Caddyfile, one rsync target is operationally simpler and the user provisioned exactly one subdomain. 3. **Caddy 2 alpine image, auto-TLS via Let's Encrypt.** No manual cert management. HTTP/2, gzip + zstd encoding, and HSTS preload enabled. TLS 1.2+ enforced (TLS 1.3 preferred); legacy ciphers disabled. 4. **Docker Compose single-service deployment.** One service (`registry`), volume-mounted `/srv/registry/` for content, named volumes for Caddy data + config so cert state survives container restarts. 5. **CI builds + rsync deploy.** GitHub Actions runs on push to `genesis` when `packages/react/react-ui/registry/**`, `packages/react/react-ui/src/**`, or `packages/react/react-ui/.storybook/**` changes. The job builds the registry JSON via `bun packages/react/react-ui/scripts/build-registry.ts`, builds the Storybook static via the package's existing `build-storybook` script, then rsyncs both into `/srv/registry/r/` and `/srv/registry/storybook/` respectively. Caddy picks up changes live (no restart needed for static files). 6. **Scoped deploy SSH user, not `root`.** The user noted `ssh root@ovh` works for ad-hoc admin tasks. For CI we deploy as a dedicated `deploy-registry` user with a single sudoers rule permitting `rsync` into `/srv/registry/` only. This blast-radius reduction is the standard hardening; the user retains `root@ovh` for all manual operations. 7. **Phase 2 deferred.** A Bun container that renders live preview pages at `https://ui.suaveplan.com/preview/` is explicitly out of scope. It would need its own image, its own service in `docker-compose.yml`, and a reverse-proxy block in the Caddyfile. Land it after one or two real consumers are using the registry. ## File layout ```text ops/registry/ Dockerfile # FROM caddy:2-alpine, copies Caddyfile in Caddyfile # ui.suaveplan.com { ... } block, two paths docker-compose.yml # single registry service, named cert volumes README.md # full deployment runbook (lives here, not in this change) .env.example # OVH_SSH_HOST / OVH_SSH_USER placeholders, no secrets .github/workflows/ registry-deploy.yml # build registry + storybook, rsync, reload packages/react/react-ui/scripts/ build-registry.ts # generates registry JSON from package source ``` The deployment runbook (`ops/registry/README.md`) is created as part of this change but its content is operational and long; this proposal references it without inlining it. ## What changes ### ADDED - `ops/registry/Dockerfile` — `FROM caddy:2-alpine` with the Caddyfile copied to `/etc/caddy/Caddyfile`. No app code, no Bun. - `ops/registry/Caddyfile` — `ui.suaveplan.com` site block with `root * /srv/registry`, two `handle_path` blocks for `/r/*` and `/storybook/*`, `encode gzip zstd`, `header` block setting HSTS - `X-Content-Type-Options` + `Referrer-Policy`, `tls` directive pinned to `min_version 1.2`, and a `/health` endpoint returning `200 OK` from a static `respond` directive. - `ops/registry/docker-compose.yml` — single `registry` service using the local Dockerfile, mounts `/srv/registry/:/srv/registry/` read-only, named volumes `caddy_data` and `caddy_config` for cert state, exposes `80:80` and `443:443`, restart policy `unless-stopped`. - `ops/registry/README.md` — deployment runbook covering first-time setup, on-deploy flow, rollback, secret rotation, and the manual vs. CI access split (root for ad-hoc, scoped user for CI). - `ops/registry/.env.example` — placeholders for `OVH_SSH_HOST`, `OVH_SSH_USER`, `REGISTRY_DEPLOY_PATH`, `STORYBOOK_DEPLOY_PATH`. No secrets; instructions point to GitHub Actions secrets for the SSH private key. - `.github/workflows/registry-deploy.yml` — workflow triggered on push to `genesis` with path filters. Steps: checkout, setup Bun, install deps via the cached lockfile, build registry JSON, build Storybook static, set up SSH agent from secret, rsync both trees, optionally call `caddy reload` (no-op for pure static updates but harmless and documents the pattern for future config changes). - `packages/react/react-ui/scripts/build-registry.ts` — generates the per-component registry JSON tree from the package source. Lives in the package (not in `ops/`) because it consumes the package's own components and types; keeping it co-located means it builds correctly under `bunx turbo build --filter=@suaveplan/react-ui` and the registry shape stays in sync with the components it describes. Emits to `packages/react/react-ui/registry/r/*.json` matching the shadcn registry schema (`registry-item` v1). ## Verification approach Per CLAUDE.md "Web App & Page Verification" — a 200 response is not proof of correctness. Every requirement is tied to a verification step. 1. **TLS + cert validity.** After first deploy, verify `openssl s_client -connect ui.suaveplan.com:443 -servername ui.suaveplan.com /dev/null | openssl x509 -noout -dates -subject -issuer` shows a valid Let's Encrypt cert with `subject=CN=ui.suaveplan.com` and `notAfter` ≥ 60 days out. 2. **Registry JSON schema conformance.** A Playwright check fetches `https://ui.suaveplan.com/r/index.json` plus a sampled subset of per-component manifests and validates each against the `registry-item` schema from the shadcn project. 3. **Storybook static loads cleanly.** Playwright navigates to `https://ui.suaveplan.com/storybook/`, asserts the Storybook root element renders, asserts zero `console.error` and zero `console.warn` over a 5-second observation window, and asserts every loaded subresource returned 2xx (checked via `page.on('response', ...)` capturing every request that fired during navigation). 4. **End-to-end shadcn add.** A Playwright test creates a throwaway Vite + React project in a temp dir, points its `components.json` at the public registry, runs `npx shadcn@latest add @suaveplan/button`, and asserts the file landed in `src/components/ui/button.tsx` with the expected exported `Button` symbol. This is the contract test that proves the registry actually works end-to-end. 5. **Caddy access log sanity.** After 24 h of live traffic, the Caddy access log SHALL show a 5xx rate of zero across the verification window (`docker compose exec registry cat /var/log/caddy/access.log | grep -E ' 5[0-9]{2} ' | wc -l`). 6. **Idempotent deploy.** Re-running the GitHub Action with no source changes SHALL leave the live tree byte-identical (verified by checksum comparison against the previous deploy snapshot). 7. **Rollback works in one command.** The runbook documents `bash ops/registry/rollback.sh`; the verification test triggers it on the OVH host and confirms `https://ui.suaveplan.com/r/index.json` serves the previous registry contents. Each requirement in `specs/registry-deployment/spec.md` ends with a `**Verified by:**` line naming the concrete check. ## Security considerations 1. **Read-only registry.** Caddy serves files; there is no write endpoint. Consumers GET; nobody POSTs. CORS is closed by default except for the registry JSON path, which sets `Access-Control-Allow-Origin: *` to permit `npx shadcn` from any origin (the JSON is public anyway). 2. **Scoped deploy SSH user.** CI logs in as `deploy-registry`, not `root`. The user has a single sudoers rule: `deploy-registry ALL=(root) NOPASSWD: /usr/bin/rsync --server - /srv/registry/`. Even if the GitHub Actions secret leaks, the blast radius is "overwrite the public registry" — not "own the OVH host". The user's`ssh root@ovh` access remains for ad-hoc admin work and is fine for that purpose; the recommendation is purely to scope CI down. 3. **TLS 1.2 minimum.** Caddyfile pins `tls { protocols tls1.2 tls1.3 }`. HSTS is set with `max-age=31536000; includeSubDomains; preload` and the domain is to be added to the HSTS preload list after a 90-day soak. 4. **No secrets in committed files.** `ops/registry/.env.example` uses `__PLACEHOLDER__` strings only. The actual SSH private key, host, and user live in GitHub Actions secrets named `OVH_SSH_PRIVATE_KEY`, `OVH_SSH_HOST`, and `OVH_SSH_USER`. The README documents these names but never their values. 5. **No `.well-known` leakage.** The Caddyfile explicitly does not serve `/.well-known/*` from the registry root (Caddy's ACME handler manages its own `/.well-known/acme-challenge/` path internally; nothing else under `.well-known` is exposed). 6. **Header hardening.** Every response carries `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and `Permissions-Policy: interest-cohort=()`. Storybook responses additionally carry a Content-Security-Policy that permits inline styles (Storybook needs them) but blocks third-party scripts. ## Operational runbook The full runbook lives in `ops/registry/README.md` after implementation. Key flows: - **First-time setup.** SSH to OVH; clone genesis; `cd ops/registry`; `cp .env.example .env` and fill in real values; `docker compose up -d`; wait 30–60 s for Let's Encrypt to issue; verify `curl -fsSL https://ui.suaveplan.com/health` returns `200 OK`; create the `deploy-registry` user and install the CI public key as documented. - **On-deploy.** Push to `genesis`. GitHub Action runs on path filter; rebuilds registry + Storybook; rsyncs to OVH. Caddy serves the new content immediately (static files; no reload needed). - **Rollback.** Each deploy snapshots the previous tree to `/srv/registry/.previous//`. The `ops/registry/rollback.sh` script (shipped in the runbook) atomic- swaps the current tree with the most recent snapshot. Documented as one-shot: `bash ops/registry/rollback.sh`. ## Out of scope - Phase 2 live preview pages. Adding a Bun container that server-renders `https://ui.suaveplan.com/preview/` is deferred until at least one consumer is using the registry. It needs its own image, service, and reverse-proxy block. - Multi-region CDN. Cloudflare in front of OVH would help on global read latency but is unjustified at zero traffic. Defer to first measured scale problem. - Authenticated registries. Private packs and paid themes need signed URLs and a token-issuance flow. Not in v1; the public registry is the only consumer of this domain. - Versioned registry trees (multiple major versions side-by-side). Not needed until `@suaveplan/react-ui` cuts a 1.0 and starts publishing 2.0 in parallel. The single-version registry covers every pre-1.0 state. - Webhooks or push-notification of upstream changes. The pull-on-demand model fits shadcn's CLI behavior; consumers run `npx shadcn@latest add` when they want updates. - A web UI for browsing the registry. Storybook IS the browse UI in v1. ## Impact ### Affected dependents This change does not modify any genesis package source. It scaffolds ops infrastructure plus one new script inside `@suaveplan/react-ui`. The blast radius is therefore the new script and any future consumer of the public registry. - `@suaveplan/react-ui` — [x] tests run / [x] docs reviewed / [ ] no action needed (reason: this change adds `scripts/build-registry.ts` to the package; the package's own test suite must continue to pass after the script is added, and the registry-build smoke test is wired into the package's CI alongside its existing checks). - All future external consumers of `npx shadcn@latest add @suaveplan/` — no in-repo dependents to test today; the contract test in step 4 of the verification approach is the binding compatibility check. For all other genesis packages: no action needed (the ops change does not touch their source, dependencies, or build outputs).