Files
suaveplan/openspec/changes/react-ui-registry-deployment/proposal.md
2026-05-27 23:29:53 +01:00

307 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<component>`) 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/<component>` 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/<component>` 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 2>/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/<utc-timestamp>/`. 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/<component>` 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/<component>` — 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).