- docs/ANALYTICS.md: privacy model, the two data sources, dashboard tour,
snippet + WordPress mu-plugin + nginx sub_filter install paths, config table,
geolocation, scale/PostgreSQL guidance, collector protections, and the
single-worker note. Clarifies the panel-extension vs WP-plugin naming and that
the Umami/Plausible/PostHog templates remain available.
- Recommend serverkit-analytics for the wordpress + web-apps setup-wizard use
cases (RECOMMENDED_EXTENSIONS_BY_USE_CASE). Onboarding guard test green.
- CHANGELOG (Unreleased): Web Analytics feature entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- log_ingest_service.py: generalized apache/nginx combined-log parser (skips
assets, non-GET, and bots so log hits approximate real pageviews). Incremental
per-site cursors — file sources use (inode, byte_offset) with rotation/
truncation reset; docker sources use `docker logs --since <last_ts>` with a
ts-based boundary dedup. Events tagged source='log' so JS vs log stay
comparable. run_log_tail (scheduled) + ingest_site.
- geo.py: optional country-only GeoLite2 lookup, off by default, cached reader,
returns None on any miss (no package / no DB / private IP). Wired into JS
build_event and log ingestion; no IP is ever stored.
- analytics.py: admin POST /sites/<id>/ingest-logs (ops/testing trigger).
- 12 new tests (parser, asset/bot/non-GET filter, log events, since-dedup, file
cursor advance, disabled skip, run_log_tail + global toggle, endpoint, geo
off/no-db, country null default). 83 total.
The realtime Socket.IO namespace is intentionally deferred (a plan fast-follow);
the Realtime tab already polls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- wp_integration.py: writes a ServerKit Analytics mu-plugin into a managed WP
site. Docker sites (the default template uses a named volume, not a bind mount)
get the file via `docker exec tee` into /var/www/html/wp-content/mu-plugins;
bare-metal sites via a host write + www-data chown. Idempotent, remove-on-
disable, feature-detects serverkit-wordpress, remove_all_injections on
uninstall.
- nginx_integration.py: injects an idempotent, marker-guarded sub_filter block
(rewrites </body>) into a managed app's vhost, following the WAF include-
injection precedent. Validates with nginx -t and REVERTS the file if the test
fails (never leaves nginx broken); reloads (never restarts).
- analytics.py: admin POST/DELETE /sites/<id>/inject/{wordpress,nginx}.
- AnalyticsTab.jsx: feature-detects the extension and shows a banner linking to
the persistent dashboard, keeping the on-demand log view as a complement.
- 18 new tests (mu-plugin PHP, WP inject/idempotent/docker-exec/remove/
remove-all, wordpress_available, nginx block insert/strip/idempotent/revert-
on-invalid/remove, admin-gated endpoints). 71 total. lint + build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- rollup_service.run_rollup: DB-side aggregation of raw pageview events into the
daily table (overall + page/referrer/device/browser/os/country dims), with
single-pageview bounce detection. Idempotent + backfill-safe (recomputes every
(site,date) with raw events).
- report_service: rollup-backed overview/pages/referrers/devices/timeseries with
a range parser (range= or start/end), plus realtime read from raw events.
Documents the sum-of-daily-uniques range caveat.
- site_service: tracked-site CRUD (create/update/rotate-key/delete with data
cascade) + hostname normalization.
- analytics.py: JWT'd endpoints — /sites CRUD + /sites/<id>/{overview,pages,
referrers,devices,realtime,timeseries} + /rollup. Viewer reads, admin
mutations (tramo/k8s/mail RBAC precedent).
- 15 new proving tests (rollup overall/dims/idempotent, overview/pages/realtime,
range parse, full CRUD, RBAC viewer-read/admin-mutation, 401 unauth). 54 total.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Raise BASELINE_COUNT 2623 -> 2637 for the two new files added by plan 48
(test_trusted_client_ip.py +7, test_login_bruteforce.py +6). Full suite:
2584 passed, 53 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces a new `.coderabbit.yaml` with ServerKit-specific review guardrails, and expands env/security documentation for trusted proxy headers and per-IP login brute-force throttling. It also adds inline backend notes clarifying `request.remote_addr` behavior under ProxyFix for auth, cron, and pairing paths, and updates the frontend plugin manifest to remove the Tramo and K8s plugin entries.
Add an in-memory (single-worker) per-IP throttle that complements the per-user
account lockout: it stops password-spraying across many accounts from one IP
and prevents a single attacker draining the shared login rate-limit bucket.
- new app/services/auth_throttle_service.py — lock-guarded TTL map with
is_blocked / register_failure / reset (+ opportunistic memory sweep)
- config: AUTH_IP_MAX_ATTEMPTS (10) / AUTH_IP_WINDOW_MINUTES (15) /
AUTH_IP_BLOCK_MINUTES (15)
- wire into /login (block up front -> 429 + Retry-After BEFORE the password
check; register on bad user/password; reset on correct password),
/login-links/redeem, and the 2FA /verify endpoint
- per-user lockout and the flask-limiter 5/min decorator kept (defense in depth)
- tests: test_login_bruteforce.py (6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rate-limit buckets, login lockout and audit-log source IPs hand-parsed the
leftmost X-Forwarded-For token — a client-controlled value — letting an
attacker rotate it to dodge per-IP limits or forge audit trails.
- config: add TRUST_PROXY_HEADERS / TRUSTED_PROXY_HOPS (+ _env_bool/_env_int)
- create_app: wrap in gated Werkzeug ProxyFix (rightmost trusted hops) before
the limiter/handlers so every remote_addr consumer sees the real client
- new app/utils/client_ip.py::get_client_ip() — the single trusted seam
- replace all six leftmost-XFF parsers (rate_limit, audit middleware,
api_key_auth, api_analytics, audit_service, ddns) with get_client_ip()
- deploy: root .env.example ships TRUST_PROXY_HEADERS=true (behind nginx);
backend/.env.example documents it off for dev
- tests: test_trusted_client_ip.py (7) incl. real flask-limiter bucket isolation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Updates the create-pr skill guidance to stop using conventional-commit prefixes in PR titles and require plain, teammate-friendly titles instead. It adds clear good/bad examples, keeps formatting constraints (capitalized first word, no trailing period, ~70 chars), and clarifies optional subsystem prefixes like "Backend:". It also documents that release bump behavior is driven by commit messages on dev, so bump intent should stay in commits rather than PR titles.
Replace the old detailed logo SVG with the simplified 32x32 brand mark used elsewhere in the UI. The new asset uses the current accent gradient and white server glyph so the logo stays consistent across the sidebar, auth flows, setup screens, and favicon.
PageTitleUpdater reads branding values (panelTitle/publicTitle) via useAuth, so it must be rendered as a descendant of AuthProvider. Previously it was placed above the provider tree, causing it to run without auth context.
The installer's node_ready() now gates on Node ^20.19.0 || >=22.12.0 (was >=18),
so the T3 assertion "node_ready true for node v18" was stale and failed CI.
Rewrite T3 to prove the real floor: accepts 20.19+/22.12+ (with npm), rejects
v16/v18/20.17/20.18/21.x/22.11. (Dropped an npm-absent sub-case that can't be
isolated in-harness — command -v npm still finds the runner's real npm.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make `update.sh` fetch the CI-built release tarball by default instead of
rebuilding the SPA on the server, so a normal update needs no Node/npm on the
box. This keeps updates working on old distros, ARM/Pi and tiny hosts where the
vite 8 toolchain (Node 20.19+/22.12+) can't or shouldn't run — the Node
requirement stays a build-time/CI concern, not a runtime one.
On-box source rebuilds remain available and explicit: --source / --main (main
branch), --branch <name>, or BUILD_FROM_SOURCE=1. --branch now implies source
mode (clears the release default). npm is only required for those paths.
Test: test_update.sh +1 (mode mapping — default=release, --source/--branch/
BUILD_FROM_SOURCE=source). 49 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slack pulled its brand from Simple Icons, so react-icons removed SiSlack (only
SiSlackware, the Linux distro, remains). Under vite 8 / rolldown this is a hard
build error (MISSING_EXPORT) rather than a silent no-op. Swap the Connections-hub
Slack glyph to Font Awesome's stable FaSlack (same react-icons/fa pattern already
used for route53's FaAws). Verified with a vite 8 production build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The vite 8 upgrade (dcf05d1) makes the frontend build require Node ^20.19.0 ||
>=22.12.0 — on anything older npm silently skips rolldown's native binary and
`npm run build` dies with a cryptic MODULE_NOT_FOUND. Servers that build the SPA
on-box (source install + every update) would break their next update. So:
- install.sh node_ready() now gates on the real vite 8 floor (was Node >=18) and
provisions NodeSource 22 LTS when the host Node is too old.
- update.sh gains _node_build_ok(): build_frontend_bundle refuses (returns 1 →
caller rolls back) and the Docker-host rebuild halts, both with an actionable
"upgrade Node" message instead of a rolldown stack trace.
- Dockerfile + CI (release, release-smoke, extensions, scripts-nightly) bumped
node 20 -> 22 so the shipped/pre-built bundle is built on a compatible Node.
Also fixes a pre-existing parity bug the operator caught: update.sh rendered the
systemd unit WITHOUT substituting @BIND_HOST@ (install.sh does), so an update
left the literal placeholder and gunicorn would fail to bind. update.sh now
substitutes it (loopback default, SERVERKIT_BIND_HOST override) like install.sh.
Tests: test_update.sh +3 (node-floor gate, build refusal on old Node,
@BIND_HOST@ substitution) — 48 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two gaps for existing installs:
- The tint ran only in install.sh, so `serverkit update` (which has its own
build path in scripts/update.sh) would rebuild with the DEFAULT color and a
fixed favicon.ico — existing installs would never get a unique mark.
- Re-tinting on every rebuild would also repaint the box on each update.
Fix: assign the color ONCE and persist it in /etc/serverkit/favicon-color
(survives updates), then re-apply the SAME color from both install and update.
update.sh applies it to the staged dist unconditionally (after the guarded
build), so it works whether dist was rebuilt (source mode) or shipped prebuilt
(release). An install that predates the feature picks up a color on its first
update and keeps it thereafter — a car keeps its paint.
Verified: bash -n on both scripts; persistence confirmed (re-run reuses the
stored color).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The install-time tint only recolored favicon.svg, but the page also shipped a
fixed raster favicon.ico + .png — and favicon-hash scanners hash /favicon.ico,
which was identical across every install. So the "car-paint" tint didn't change
what those tools cluster on.
- index.html links only the SVG now (drop the raster <link>s).
- Remove the unused favicon-16x16/32x32.png.
- install.sh: after tinting favicon.svg, copy it over favicon.ico too, so a
blind /favicon.ico fetch also returns the per-install color instead of a fixed
icon. (SVG bytes under an .ico name — browsers use the linked SVG; this only
affects what a scanner hashes at that URL.)
Still obscurity/defense-in-depth, not a real gate — but now it does what it says.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues from PR review:
- Login: the AuthLayout wrapper only reached the 2FA and login-link states —
the primary login form still had a hardcoded auth-container/auth-card, so the
`login_layout` setting didn't affect the page most users see. Wrap the main
form in AuthLayout too.
- apps.py: the legacy scalar-port fallback for a BYO-image app omitted `expose`,
which AppPortService defaults to `public` (binds 0.0.0.0), publishing the port
past nginx. Default it to `local` (loopback). (Workers set no port, so this
was latent, but the generic path is now safe.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lockfile had drifted from package.json (which is why `tramo` — a declared
dep — was never installed and `vite build` failed to resolve `tramo/react`).
`npm install` reconciled node_modules; commit the resulting lockfile so `npm ci`
installs are reproducible and the frontend builds cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A fixed list of N colors means two installs collide on the identical hex. Use
the "car-paint" model instead: a curated base hue (tasteful families, no muddy
yellows) + a few degrees of jitter + randomized saturation/lightness (rich,
mid-dark — never washed-out). Same family can recur, but the exact shade rarely
does, so favicon-hash comparison across installs almost always differs. Emitted
as an hsl() fill (SVG renders it natively; still a pure text replace).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reduce how identifiable a panel is from its unauthenticated surface, without
losing the brand once you're inside.
- Public vs interior title: new `public_title` setting (default "Control
Panel") drives the sign-in/register/setup headings and the browser tab
before login (and the static first-paint <title>); `panel_title`
(ServerKit) still names the tab once signed in. PageTitleUpdater picks by
route. Both are exposed pre-auth on /auth/setup-status and editable in the
admin Site Settings → Panel Appearance card.
- Per-install favicon tint (install.sh randomize_favicon): the favicon is a
plain-text SVG, so each install recolors the tile to one of a curated set of
legible colors via a string replace — same mark, different color, so a
favicon-hash comparison across installs differs. No image tooling required;
if the step can't run, the shipped default color stays. Runs on both fresh
and release installs; existing installs are untouched.
Defense-in-depth / obscurity only — real protection is access control + auth.
Verified: eslint 0 errors, backend py_compile ok, install.sh `bash -n` ok,
favicon sed recolor confirmed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the panel's identity admin-configurable instead of hardcoded, and let
operators pick a login-page layout — so instances can be branded/varied from
the panel rather than by editing source.
- Settings: new `panel_title` (default "ServerKit") and `login_layout`
(default "centered") keys via SettingsService (key/value — no migration).
- Exposed pre-auth on the existing GET /auth/setup-status so the login page
reads them before any authenticated call.
- Frontend threads panelTitle + loginLayout through AuthContext; document.title
(App.jsx PageTitleUpdater + usePageTitle) and the auth-page headings use the
configured title, with import.meta.env.VITE_PANEL_TITLE as the build-time
default for the first paint.
- Login page refactored to an AuthLayout selector with three variants —
Centered (current), Split-hero, Minimal — chosen by `login_layout`.
- Admin UI: a "Panel Appearance" card (title field + layout dropdown) in the
admin-only Site Settings tab; saves via the generic settings API and calls
refreshSetupStatus so the running SPA updates live. Searchable via a new
settingsIndex entry.
Register/Setup adopt the configured title on their headings. manifest.json
stays static (documented limitation). Verified: eslint clean on all touched
files (0 errors), settings-index guard passes. (The repo's full `vite build`
currently fails on an unrelated pre-existing plugin import, serverkit-tramo →
`tramo/react`, independent of this change.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restore the ServerKit name, favicon, manifest, auth-page headings, and
service-worker strings. Genericizing the panel's identity was the wrong
trade: hiding the title/favicon is security-by-obscurity that a real scanner
sees through, and it erased the product identity for almost no benefit. The
pages it touched (login/setup/register) require server or admin access to
reach anyway, so there is nothing to hide there.
The one durable, no-cost measure is kept: the `X-Robots-Tag: noindex`
header (from the prior commit) stays on both nginx server blocks so the
panel isn't indexed by search engines. Real protection lives in access
control + auth, not in the favicon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These raster favicons (favicon.ico/.png) were unreferenced dead files — the
app only ever served /favicon.* from public/. Removing them completes the
favicon cleanup from the de-fingerprint change; the served icon is the
generic public/favicon.svg.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reduce how easily the panel can be enumerated by scanners (Shodan/Censys)
and search engines:
- nginx: emit `X-Robots-Tag: noindex, nofollow, noarchive` on the panel
server blocks (both the TLS and insecure fallback configs).
- Generic page identity: title "Control Panel" + neutral meta description
in index.html; the runtime document.title setters (App.jsx, usePageTitle)
and PWA manifest name/short_name match — so the browser tab and manifest
no longer say "ServerKit".
- Drop the distinctive favicon (.ico/.png removed, generic favicon.svg) so a
favicon-hash no longer clusters instances; strip the product name from the
service worker (cache name, push title, header comment).
- Neutralize the visible auth/splash copy (Login/Register/Setup headings,
AppLoader label) to a generic name.
Search engines are the easy case; the header is defense-in-depth. Real
hiding still requires access control (IP allowlist / VPN / zero-trust) and
is deployment-specific.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- config: is_insecure_secret() rejects any dev/placeholder key (dev-*,
jwt-secret-key*, *change-in-production*, *change-this*, empty), not just the
two exact default strings — a variant like `dev-jwt-...` can no longer boot
in production. Used in both ProductionConfig.__init__ and init_app.
- systemd unit: bind gunicorn to 127.0.0.1 by default so the raw API port is
not world-reachable (host nginx already fronts :80/:443). Override with
SERVERKIT_BIND_HOST=0.0.0.0 for setups that front it differently.
- cli break-glass: mint the token with a string identity; flask-jwt-extended
rejects a non-string `sub` at decode time with a 422, which broke every CLI
call that authenticated against the panel API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A docker app created with a `docker_image` but no repository (root_path /
compose_file NULL — e.g. an `image:` manifest service with no git source)
previously skipped the launch branch in start_app entirely and was just
marked `running`, so nothing ever started.
On first start, materialize a one-service compose from the image and its
typed ports under APPS_DIR, persist root_path/compose_file, and let the
normal compose_up overlay inject the app's effective env (including
resolved vault secrets) — so nothing sensitive is written to the base
compose. No-op when a source already exists, there is no image, or the
app targets a remote server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan 47 Phase 6 (for shipped work). Sync the pre-bundled frontend plugin.json
copies for the four extracted builtins (entry_point/url_prefix + status
description) so builtin-extensions/ and frontend/src/plugins/ agree
(sync-builtin-frontends --check + test_extension_manifests green). Raise the
test-count ratchet 2588 -> 2623. Document the second-speed backend-extraction
recipe in EXTENSIONS.md, add CHANGELOG entries (wizard installs + opt-in
FTP/cloud/remote-access/status), and a README "lean by default" bullet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan 47 Phase 3a. SECURITY_TABS' TabGroupLayout now passes groupId="security"
and mounts {groupRoutes.security}, so extracted security features (fail2ban,
clamav, …) can contribute tabs + group-nested routes via their manifests and
return as tabs in the exact place users know — the same mechanism the
files/servers/monitoring groups already use. Inert until a security extension
ships; no behavior change on its own.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan 47 Phase 2. The status-pages blueprint (public /status/<slug> + /badge +
authenticated management API) and its service move into
builtin-extensions/serverkit-status/backend/ (entry_point status_pages:
status_pages_bp, url_prefix unchanged). Core no longer registers /api/v1/status
(management) or imports the service; the unused /api/v1/observability/status-pages
alias is dropped. The StatusPage/StatusComponent/etc models stay core (G2). The
public status/badge pages work whenever the extension is installed.
The core WordPress health-check job (run_health_checks) now reaches
StatusPageService.sync_component_from_health via get_installed_extension_attr and
no-ops cleanly on a lean panel.
Tests: test_status_extraction.py (8) — extraction shape, install=routes,
health-job no-op, relocated status-page mutation + component-check authz.
Adjusted test_observability_namespace + test_api_authz_hardening accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan 47 Phase 2. The tunnels blueprint + broker/publish/netutil services move
into builtin-extensions/serverkit-remote-access/backend/ (entry_point tunnels:
tunnels_bp, url_prefix unchanged). Core no longer registers /api/v1/tunnels or
imports the services; the Tunnel/ExposedService models stay core (G2).
New reusable core->extension seam get_installed_extension_attr(slug, module,
attr): the agent gateway reaches TunnelBrokerService.schedule_reconcile through
it, so tunnel self-heal still fires when the extension is installed and no-ops
cleanly otherwise. Backend re-acquisition on upgrade is covered by the Phase-2
migration.
Tests: test_tunnel_netutil.py + test_tunnel_reconcile.py re-homed into
test_remote_access_extraction.py (17), which installs the extension and runs the
helpers/reconcile against its modules.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan 47 Phase 2. The cloud-provider blueprint + service move out of core into
builtin-extensions/serverkit-cloud-provision/backend/ (entry_point, url_prefix
unchanged). Core no longer registers /api/v1/cloud or imports the service; the
CloudProvider/CloudServer models stay core (G2). The one-time legacy cloud-secret
encryption is dropped from core boot (already run on any panel reaching this
version; the extension encrypts on write).
Also adds run_backend_acquisition() — a one-shot migration that re-acquires the
now-extracted backend on upgraded panels that installed a converted builtin
frontend-only (whose API used to come from core). Without it, ftp/cloud/etc would
lose their API on upgrade. Wired into create_app after run_auto_install.
Tests: test_cloud_provision_extraction.py (7, incl. relocated secret-at-rest +
backend-acquisition migration); secret-encryption cloud block moved out of core.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan 47 Phase 2. The FTP blueprint + service move out of core into
builtin-extensions/serverkit-ftp/backend/ (entry_point ftp:ftp_bp, url_prefix
unchanged). Core no longer registers /api/v1/ftp or imports ftp_service — a
fresh panel that never touches FTP loads none of it. Already in
CONVERTED_BUILTIN_SLUGS, so upgrades re-acquire the backend automatically.
Acceptance (tests/test_ftp_extraction.py): uninstalled = no /api/v1/ftp routes +
app.services.ftp_service gone; installed = blueprint hot-loads and /ftp/status
responds; uninstall removes it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan 47 Phase 1. The onboarding "Recommended for you" step now installs real
extensions matched to the selected use cases instead of rendering decorative
strings:
- Backend use-case -> slug map + resolver (RECOMMENDED_EXTENSIONS_BY_USE_CASE,
recommend_extensions_for_use_cases) single-sourced and test-guarded against
dead slugs; exposed via GET /api/v1/plugins/recommendations.
- SetupStepSummary renders default-checked install checkboxes with each
extension's real display_name/description and installs source-aware on Finish
(per-item progress, fail-soft: an install failure never blocks setup).
- complete-onboarding persists onboarding_installed_extensions.
- WordPress flagship seeding is now gated on needs_setup(): fresh installs skip
it (the wizard installs it when the WordPress use case is picked); completed
-setup/upgraded installs keep it (zero regression). TESTING bypass keeps the
WordPress suite green; the real gate is covered by dedicated proving tests.
Proving tests: tests/test_onboarding_recommendations.py (12).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The release workflow builds linux/amd64,linux/arm64 via buildx + QEMU on
an amd64 runner, so the frontend-builder stage ran `npm ci`/`npm run
build` under emulation for arm64 -- 20-40 minutes when it did not hang
outright. Its output (frontend/dist) is static, arch-independent assets,
so pin the stage to $BUILDPLATFORM: buildx runs it natively, once, shared
by both platform images. Final images are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>