From bde745e0510cc55d0beeb1e6fdff2a2c619dd25a Mon Sep 17 00:00:00 2001 From: Muhideen Mujeeb Adeoye Date: Mon, 27 Jul 2026 07:36:28 +0100 Subject: [PATCH 01/35] docs: record owner acceptance of phase 7 with tracked phase 8 conditions --- docs/reviews/phase-7.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/reviews/phase-7.md b/docs/reviews/phase-7.md index 453d153b..cc183166 100644 --- a/docs/reviews/phase-7.md +++ b/docs/reviews/phase-7.md @@ -2,7 +2,31 @@ ## Status -**Rejected pending fixes** +**Accepted with owner note (2026-07-27)** — original review verdict below was +"Rejected pending fixes"; the product owner subsequently accepted Phase 7 with +the following note. + +### Owner Acceptance Note (2026-07-27) + +The product owner accepts Phase 7 and classifies Mosaic as v1 feature complete +for the purpose of starting Phase 8, with these explicit conditions: + +1. The outstanding post-fix runtime evidence listed under "Required Path to + Re-review" (migration 17/18 down/up cycle, complete Flutter and Android + suite reruns, the post-fix end-to-end Experiment demonstration, and the + dashboard relay/browser path) is tracked as mandatory Phase 8 verification + work. It must be completed and evidenced during Phase 8 before GA; it may + not be silently dropped. +2. The open Phase 6 ingestion-boundary defect (event-specific correlation, + attribution, and rollout-minimization rules enforced by the protocol tool + but not by the canonical schema or API ingestion path) is authorized as + in-scope Phase 8 data-integrity/security work and is classified as a GA + release blocker until fixed and verified. +3. The `v1.0.0-rc.1` tag at commit `2c4f272` is confirmed by the owner as the + accepted v1 release-candidate baseline for Phase 8. +4. No new feature scope is authorized by this acceptance. + +**Rejected pending fixes** (original verdict, retained for the record) Phase 7 implements the planned Experiment protocol, backend, dashboard, and native SDK surfaces, and the available source-level validation is green. Two From 8a4c8985645d6c18c2165bec683b1aed82e0d440 Mon Sep 17 00:00:00 2001 From: Muhideen Mujeeb Adeoye Date: Mon, 27 Jul 2026 07:56:25 +0100 Subject: [PATCH 02/35] docs: add phase 8 operational hardening plan (stage 1 draft) --- docs/plans/phase-8-operational-hardening.md | 442 ++++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 docs/plans/phase-8-operational-hardening.md diff --git a/docs/plans/phase-8-operational-hardening.md b/docs/plans/phase-8-operational-hardening.md new file mode 100644 index 00000000..c496f61a --- /dev/null +++ b/docs/plans/phase-8-operational-hardening.md @@ -0,0 +1,442 @@ +# Phase 8 Plan: Operational Hardening and v1 General Availability + +Status: Accepted (owner-approved orchestration plan, 2026-07-27). Sections marked +`[pending Stage 1B]` are completed after the protocol and SDK audits reconcile. + +## Baseline + +- Base commit: `2c4f272` (`v1.0.0-rc.1`), owner-confirmed as the accepted v1 + release-candidate baseline. +- Branch: `phase/8-operational-hardening`. +- Phase 7: accepted with owner note (see `docs/reviews/phase-7.md`); its missing + runtime evidence is mandatory Phase 8 verification work. +- Phase 6 ingestion-boundary defect: owner-classified GA release blocker, + in-scope for Phase 8. + +## Feature-Freeze Policy + +Phase 8 accepts only: release-blocking defect fixes; security, authorization, +data-integrity, migration, backup/restore, reliability, performance, +accessibility, and compatibility fixes; observability, deployment, operational +tooling, documentation, installation, upgrade, and troubleshooting +improvements. + +Phase 8 rejects: new Paywall components, Studio capabilities, commerce +providers, Product types, targeting operators, analytics products, Experiment +capabilities, Mosaic Billing, AI assistance, workflow automation, and new +infrastructure categories without an accepted ADR. Specifically refused even if +argued as hardening: `mosaic dev` CLI, Prometheus endpoint beyond existing OTLP +export, release channels, Analytics Event v3 rename, Redis introduction, +resumable multipart export uploads. + +Every change must answer: what GA risk it reduces, what realistic failure it +prevents, why it is required before v1, and why it cannot be deferred. + +Boundary rulings (orchestrator, recorded): the non-functional organization +switcher, missing sign-out, and missing route guards are shipped-broken +release-blocking defects, not new features. One CI workflow is release +engineering, explicitly in Phase 8 scope. + +## Release-Blocker Policy + +A defect blocks GA if it falls into any category below. Blockers cannot ship +documented; they must be fixed and verified before a GA tag. + +1. Cross-tenant data access (Organization/Project/Environment boundary). +2. Secret or credential exposure (responses, logs, traces, diagnostics, + exports, audit events, delivered configuration). +3. Critical authentication or authorization bypass. +4. Unrecoverable data loss in a documented supported operation. +5. Migration corruption or a schema left unusable with no documented recovery. +6. Failed backup restoration. +7. Corrupted Configuration Releases (in-place mutation, accepted digest + mismatch, cross-SDK decode divergence). +8. Incorrect commerce Product resolution. +9. Purchase-result corruption. +10. Incorrect customer Entitlement interpretation (including unknown provider + state silently rendered as inactive). +11. Broken deterministic Placement or Experiment assignment. +12. Experiment exposure corruption (exposure without successful presentation, + wrong-Variant attribution, fallback counted as original exposure). +13. Analytics duplication that materially corrupts metrics. +14. Privacy export or deletion crossing tenant boundaries. +15. SDK crash in a supported critical path; analytics failure must never block + purchasing. +16. Installation or upgrade failure in the supported deployment profile. +17. Protocol contract divergence — canonical schema, semantic validator, and + API runtime path disagreeing about validity (the Phase 6 defect class). +18. GA claim without evidence — documentation asserting an undemonstrated + capability. + +A blocker is never silently downgraded; reclassification requires an explicit +owner decision recorded in the Phase 8 review. + +## Known-Limitation Policy + +A defect may ship documented only if it is outside every blocker category, has +a reachable workaround or safe visible failure, cannot cause data loss, money +loss, or a wrong monetization decision, is recorded in release notes and +`docs/known-limitations.md` (surface, platforms, symptom, workaround, planned +resolution, GA-safety statement), and is explicitly owner-accepted at the +Phase 8 gate. Environmental verification gaps ship labelled "not demonstrated", +never implied as working. Owner-accepted limitations at planning time: + +- SDKs remain pre-1.0 (`0.x-dev`), installed by git pin or local path (D6). +- Commerce adapters are implemented and contract-tested but not live-verified + against RevenueCat sandbox, Apple sandbox, or Google Play test track (D10). +- Self-hosted signup is ungated; restricting `/v1/auth/signup` is a documented + operator responsibility (D9). +- Down migrations are not a rollback strategy; irreversible migrations refuse + on affected data and rollback is restore-from-backup (R2 policy). +- Statistical scope: no peeking correction, no multiple-comparison correction, + no winner declaration (deliberate design, documented as scope). + +## Owner Decisions (resolved 2026-07-27) + +- D1 License: Apache-2.0 confirmed; add README section and manifest `license` + fields. +- D2 Protocol approval: all v1 contracts flip `releaseCandidate` to `approved` + at GA keeping identifiers; widen the two analytics manifest schemas that + hard-code the RC const first. +- D3 Analytics namespace: export-only `mosaic_*` mapping documentation; + canonical event names unchanged; closes before D2 flips. +- D4 Deployment profile: single-host Docker Compose, plus a documented + external-managed-services variant (managed PostgreSQL, S3, external TLS). +- D5 Dashboard packaging: dashboard Dockerfile and Compose service are added so + `docker compose up` yields a complete installation. +- D6 SDK distribution: SDKs stay at 0.x-dev; no registry publishing; honest + git-pin/local-path installation docs; recorded as a known limitation. +- D7 Browser matrix: Chrome/Edge 111+, Safari 16.4+, Firefox 128+ (floor set by + Tailwind v4); HTTPS or localhost required; Studio desktop-only (≥768 px). +- D8 Performance targets: publish onboarding targets only at GA (10 minutes to + first paywall, 2 minutes to publish); measure latency in Phase 8 and record + results as evidence; one hard reliability target — a Mosaic outage never + prevents cached or bundled rendering. Latency numbers publish post-GA. +- D9 Signup gating: documented operator responsibility, no gate flag. +- D10 Commerce verification: documented as not live-verified; Drill 13 runs + mock/custom-provider flows. +- D11 GA bar: "Ready for General Availability" requires every blocker closed + and the complete demo passing; "Ready with documented limitations" only for + honestly labelled environmental gaps; source-level-only evidence is not + acceptance. +- R1 fix shape: protocol schemas are embedded into the API binary via + `go:embed`, eliminating the image-packaging drift class. +- R2 rollback policy: refuse-and-restore for irreversible down migrations. +- Load tool: stdlib-only `apps/api/cmd/loadgen` approved (smallest viable + addition, no new dependency). +- Worker topology: single worker process at GA; un-gated in Compose, restart + policy, fair scheduling, health listener; multi-instance is post-GA. +- Dashboard runtime config: SSR-injected `window.__MOSAIC_CONFIG__` read once + at startup (replaces compile-time `VITE_API_BASE_URL` dependence). +- Client error reporting: none by design at GA (privacy-aligned); documented + policy plus correlation-ID surfacing. +- Hosted workspace responsive floor: desktop-first, documented like Studio. + +## Supported v1 Matrix + +- PostgreSQL: 17 (the tested version); 16 expected-compatible but unsupported. +- Redis: not used; not part of v1. +- Object storage: S3-compatible; MinIO `RELEASE.2025-07-23T15-54-02Z` is the + tested implementation (ADR 0018). +- Docker: Engine 24+ with Compose v2 plugin. +- CPU architectures: amd64 and arm64 (Go cross-compiled, distroless base). +- Dashboard browsers: per D7 above. +- Go toolchain (build-from-source): 1.26.x. +- Flutter/Dart minimums: Flutter 3.19 / Dart 3.3 floor `[confirm in Stage 1B]`. +- iOS minimum, Swift, Xcode: `[pending Stage 1B]`. +- Android minimum API, Kotlin, Gradle, AGP: `[pending Stage 1B]`. +- Protocol contracts at GA (all `approved`, exact-match readers): Paywall 0.2; + Configuration Delivery 1, 2, 3; Commerce Provider 1, 2; Commerce + Configuration 1, 2; Placement Decision 1; Analytics Event 1, 2; Experiment + Assignment 1. +- SDK compatibility window: current SDKs with current backend; legacy delivery + negotiation to v2/v1 preserved; unsupported contracts fail safely retaining + last-known-valid configuration. + +## Deployment Profiles + +Profile A (supported): single-host Docker Compose running postgres, minio, +migrate (one-shot), api, worker (un-gated), dashboard, and a TLS edge (Caddy +example). Profile B (documented variant): externally managed PostgreSQL and +S3-compatible storage with operator-provided TLS; Compose runs api, worker, +dashboard only. Everything else is documented as unsupported. Kubernetes, +Helm, and Terraform are explicitly out of scope. + +## Configuration Model + +All configuration via environment variables, documented exhaustively in +`.env.example` and the environment reference. Startup performs strict +validation: missing required production configuration fails startup with +structured, secret-free errors; production mode rejects unsafe defaults +(wildcard or `http://` CORS origins, default MinIO credentials, disabled +object-storage TLS, plaintext `DATABASE_URL` without sslmode, absent session +or keyring secrets). Development and production requirements are distinguished +by `MOSAIC_ENVIRONMENT`. Secrets are never generated silently at startup and +never printed. + +## Secret-Management Model + +Provider credentials: AES-256-GCM envelopes with scope-bound AAD under a +multi-key keyring (ADR 0019), with a new `keyring` command providing validate, +inspect (envelope count per key ID), and rotate (re-encrypt under active key). +Sessions: opaque tokens, SHA-256 stored (ADR 0017). API keys: hashed at rest, +rotation documented. Key material never enters logs, telemetry, or backups; +the keyring is backed up separately from PostgreSQL, and its loss consequences +(permanent credential undecryptability) are documented with recovery steps. + +## Startup and Shutdown Model + +API startup: validate configuration, verify PostgreSQL connectivity (bounded), +verify migration compatibility (fail closed if pending; never auto-migrate), +verify object storage, register telemetry, listen. Shutdown: on signal, +readiness flips to draining, HTTP drains within budget, background work stops, +telemetry flushes, pools close; bounded end-to-end. Worker: jobs run on a +background context with a completion budget so failure records commit even +during shutdown; lease-safe termination; a minimal health listener. + +## Health and Readiness Model + +`GET /health/live`: process up, version/build identity. `GET /health/ready`: +PostgreSQL ping, object-storage check, migration compatibility, required +encryption configuration; per-check safe diagnostic codes, no credentials or +topology; 503 while draining. No expensive full-system operations. + +## Migration, Upgrade, and Recovery Policy + +- Migrations apply only via the `migrate` command or the Compose migrate step; + API startup never mutates schema. +- `migrate` gains: per-migration (not global) generous configurable timeout, + goose session/advisory lock, `up-to`/`down-to`, `status` with pending list, + `preflight` (current vs expected version, pending list, dirty detection, + compatibility verdict), and an explicit confirmation flag for `down`. +- Migration 00018 is reshaped: `NOT VALID` + `VALIDATE CONSTRAINT` for the + analytics FKs and a concurrent index path, so upgrades do not block + ingestion on populated databases. +- Irreversible down migrations (00018, 00010, 00006) detect affected rows and + refuse with a clear error naming the restore path (R2 policy). Fake + reversibility is never created. +- Supported upgrade: verify version → verify backup → install target → preflight + → apply migrations → start → verify readiness → smoke checks. Previous + supported release for upgrade testing: `v1.0.0-rc.1`. +- Failed-migration recovery and failed-start recovery are documented runbooks + and demonstrated in Drills 2–3. + +## Backup, Restore, and Consistency Policy + +- PostgreSQL: `scripts/backup-postgres.sh` / `restore-postgres.sh` (pg_dump + custom format, checksum, metadata; no credentials inside artifacts); + restore into an isolated instance with integrity verification; retention and + encryption guidance; PITR pointers for operators who run WAL archiving. +- Object storage: `scripts/backup-objects.sh` / `restore-objects.sh` + (mc mirror), object inventory, missing-object detection, checksum + verification, orphan detection, post-restore validation. +- Consistency: a Mosaic backup is PostgreSQL + object storage + keyring + material (stored separately); documented ordering (DB snapshot first, then + bucket mirror; assets are immutable and digest-addressed so the bucket may + only be a superset). A database-only backup is not a complete backup. +- A backup procedure is accepted only after a demonstrated restore (Drills 4–5). + +## Data-Integrity Checks + +Post-restore verification covers Organizations, Projects, Products, +Entitlements, Paywalls, Configuration Releases (byte-identical by digest), +Placements, analytics aggregates, Experiments, audit history, and asset +digest resolution through the SDK path. + +## Worker-Recovery Strategy + +Fix `experiment_scheduling_jobs`: reclaim expired leases in `LeaseSchedule`, +requeue with backoff until `attempt_count >= max_attempts`, then surface as +failed with diagnostics. Preserve the correct `FOR UPDATE SKIP LOCKED` +patterns elsewhere. Fair round-robin between provider sync and analytics job +families; queue-depth and oldest-age metrics; per-job structured logging with +job/tenant/trace IDs; restart-safe and duplicate-protected by leases. + +## Rate-Limiting and Abuse-Protection Policy + +- Trusted-proxy boundary: `RealIP` honoured only when the peer is within a + configured trusted CIDR set (default: none), fixing spoofable limiter keys + and forgeable `remote_ip` logs. +- Per-surface limits (tenant-aware where authenticated): auth endpoints + (strict), configuration delivery, analytics ingestion, asset upload, + provider-connection testing, export/deletion requests, simulator and + Experiment export endpoints, plus a baseline limit for authenticated + dashboard APIs. Not one identical limit everywhere. +- Safe retry metadata (`Retry-After`), bounded request bodies everywhere, + upload limits honouring `MOSAIC_ASSET_MAX_UPLOAD_BYTES`, batch limits, + bounded query windows, observable rejections (metrics + logs). + +## Security-Review Checklist + +Authentication flows; authorization and IDOR sweep (every lookup tenant-scoped +server-side); session cookies (Secure, HttpOnly, SameSite; CSRF posture); +CORS; security headers (add HSTS and Permissions-Policy); trusted proxies; +request/upload limits and content validation; path and redirect handling; +provider-secret encryption; API-key storage and rotation; secret redaction in +logs/traces/errors; SQL parameterization; SSRF in provider/asset egress; +audit logs; error responses (no internals). Dependency scanning +(`govulncheck`, `npm audit`), secret scanning, and SBOM generation run in CI. +The Phase 6 ingestion fix (per-event correlation/attribution allow-lists, +rollout-tuple atomicity, RuleSet pairing in `analytics/validation.go`) is part +of this checklist and verified by the three canonical invalid fixtures. + +## Observability Signals and Alert Policy + +Signals: HTTP latency/error metrics (otelchi), pgxpool gauges, migration +compatibility status, configuration-delivery latency and 304/ETag hit rate, +publish success/failure and duration, object-storage operation latency and +failures, provider synchronization outcomes, analytics ingestion accepted/ +rejected counters, worker queue depth/oldest age/retries/dead-letters, +retention/export/deletion job outcomes, Placement evaluation failures, +Experiment aggregation failures, emergency stops, rate-limit rejections. +Version/build attributes on the OTel resource. No personal attributes or +secrets in telemetry. Telemetry export failure never stops Mosaic. + +Vendor-neutral alert definitions (documented, not wired to a vendor): +readiness failing > 2m; migration incompatibility; worker oldest-job age > +15m; job dead-letter occurrence; ingestion rejection rate spike; publish +failure; object-storage failure rate; pool exhaustion (EmptyAcquireCount +rising); rate-limit rejection spike on auth endpoints. + +## Runbook List + +API will not start; readiness failing; migration failed; PostgreSQL +unavailable; object storage unavailable; worker backlog; publishing failure; +Configuration Release delivery failure; analytics ingestion failure; +aggregation backlog; provider connection failure; compromised public SDK key; +compromised secret API key; compromised provider credential; failed backup; +failed restore; failed data export; failed data deletion; Experiment emergency +stop; rollback after bad release; keyring loss/rotation. Each: symptoms, +impact, diagnosis, safe commands, recovery, verification, escalation, +prevention. (Redis runbook omitted: Redis is not part of v1.) + +## Performance Targets and Load-Test Plan + +Per D8, Phase 8 measures and records; it does not publish latency SLOs. +Measured paths (loadgen + OTel + EXPLAIN ANALYZE, recorded with environment, +dataset, concurrency, duration, median/p95/p99, error rate, resource use): +configuration delivery (cold/warm/304 ratio), publish, analytics ingestion +(100-event batches), Placement evaluation, Experiment result queries and v3 +payload cost, critical dashboard APIs, worker backlog drain (seeded 10k jobs), +pool behaviour under load (acquire wait, EmptyAcquireCount). Hard target +verified by test: outage never prevents cached/bundled rendering. Only +measured release-blocking issues are fixed; no speculative optimization. + +## Versioning, Compatibility, and Deprecation Policies + +- REST: path-versioned `/v1`; additive changes allowed; breaking changes + require `/v2` plus migration guidance; no silent removals. +- Server/dashboard: one SemVer unit (`v1.0.0` at GA). +- SDKs: independent SemVer, pre-1.0 at GA per D6; each declares exact + supported contract versions; public API breaks require release-blocking + justification, compatibility analysis, migration guidance, owner approval. +- Protocol: contracts approved and immutable at GA; behaviour changes require + a new contract version; exact-match readers; negotiation selects the highest + mutually supported representation; unknown versions rejected safely with + last-known-valid retention. +- Deprecation: deprecated surface removed no earlier than two minor releases + and six months, migration guide at deprecation time; minimum SDK support + window 12 months / two minors; skip-version upgrades supported across one + minor. Final wording `[pending Stage 1B protocol report]`. + +## Release-Artifact Policy and CI Gates + +Artifacts: versioned API, worker (same image), and dashboard images; immutable +tags; source-commit metadata and `ARG VERSION` ldflags stamping; checksums; +SBOM (syft or `go version -m` + npm equivalents); root CHANGELOG; release, +upgrade, and migration notes. Nothing publishes automatically during +implementation; RCs precede GA. + +CI (single workflow, owner-approved): gofmt/vet/build/test with +`DATABASE_TEST_URL` and object-store vars set (integration suites must not +silently skip), goose up/down cycle against seeded data, protocol `npm test`, +dashboard `npm run check` (format, lint, typecheck, test, relay, build), +Flutter analyze/test, Android test/lint/assemble, iOS build/test where runners +permit (documented honestly otherwise), Docker image build + compose smoke +(`curl /health/ready`), `govulncheck`, `npm audit`, secret scan, SBOM. + +## Documentation Inventory + +Root: README rewrite (v1), LICENSE section, SECURITY.md, CONTRIBUTING.md, +CODE_OF_CONDUCT.md, CHANGELOG.md, release notes, known-limitations register; +TEST.md deleted (credential rotated first — owner action flagged). +User guides: installation, Docker Compose, environment-variable reference, +local development, administrator bootstrap, Flutter/iOS/Android quickstarts +(honest 0.x install paths), RevenueCat, custom provider, StoreKit 2, Google +Play Billing (all labelled not live-verified), Catalog, Product/Entitlement, +Studio, publishing, Placement, targeting, analytics, privacy, Experiments, +backup and restore, upgrade, security, troubleshooting. +Operator runbooks: the list above. Every documented command is validated +against the repository before the Phase 8 review signs off. + +## Minimum Sufficient Tests + +Per `docs/architecture/conventions/testing.md`; drills and integration +evidence over test-count growth. New tests (each protecting a named risk): + +1. Seeded 18→17→up migration cycle (v2 events + Experiment rows) — false-pass + rollback, unrecoverable upgrade. +2. Container smoke: compose up → `/health/ready` 200 — unstartable image (B1). +3. Experiment schedule lease recovery + transient-failure requeue — silent job + loss (B3). +4. Three canonical invalid minimization fixtures rejected by the API's own + validator path — Phase 6 blocker. +5. Spoofed `X-Forwarded-For` from an untrusted peer shares the limiter bucket + — rate-limit bypass (B9). +6. Keyring rotation: envelope written under key A decrypts after rotation to + key B — silent credential loss. +7. Readiness 503 when object storage fails while PostgreSQL is healthy. +8. Cancelled run-context still commits the job-failure record. +9. Config-validation table tests for each new production guard. +10. Dashboard: org-switcher navigation and error-not-empty rendering; hosted + `beforeLoad` redirect with `returnTo` sanitization; `hosted-query-state` + network/degraded classification; `field.tsx` aria association; focus + retention on publish/rollback success; runtime-config fallback safety. +11. Flutter: `Mosaic.dispose()` never throws on storage failure (B17) plus + equivalents found in Stage 1B for other SDK crash paths. +12. Experiment PostgreSQL integration suite (composite tenant keys, + immutability triggers, allocation invariants) — currently untested SQL. + +Explicitly not created: tests for Docker/PostgreSQL/pgx/goose/framework +internals, browser engines, OS lifecycle; no per-file test mirroring; no new +test frameworks beyond the approved CI workflow and stdlib loadgen. + +## Explicit Exclusions + +Kubernetes, Helm, Terraform, cloud-specific stacks, microservices, Kafka, +ClickHouse, other databases, other queues, gRPC, GraphQL, Mosaic Billing, AI, +new product features, automatic schema creation, automatic production +migrations at API startup, Redis, `mosaic dev`, release channels, Analytics +Event v3, registry SDK publishing (per D6), live store purchases (per D10). + +## GA Demonstration + +Full demonstration: clean environment → configure → migration preflight → +apply migrations → start API/worker/dashboard/PostgreSQL/object storage → +liveness+readiness → create administrator → Project → Products/Entitlements → +connect provider (mock/custom) → create and publish Paywall → configure +Placement → run Experiment → test purchase (custom provider) → inspect +analytics → verified backup → upgrade → verify migration and data → stop API → +SDK renders from cache → restart API → rotate SDK key → rotate provider +credential → restore backup into isolated environment → verify Products, +Paywalls, Assets, analytics, Experiments → cross-tenant access checks → +operational health. One-minute demo: install → publish native Paywall → +upgrade → survive API downtime via SDK cache → restore from backup → confirm +the product still works. + +## Stage Plan and Ownership + +- Stage 2 (write): mosaic-backend (apps/api, apps/worker, migrations, compose, + deploy, scripts, backend docs/tests) ordered WP14 → WP4 → WP9 → WP11 → WP10 + → WP12 → WP2/WP3 → WP1 → WP6/WP7/WP8 → WP5 → WP13; mosaic-dashboard + (apps/dashboard, frontend docs/tests) WP1–WP7. Non-overlapping paths; + neither touches SDKs or canonical protocol files. +- Stage 3 (write): mosaic-protocol (approval flip, policies, fixtures, + changelogs), mosaic-flutter, mosaic-ios, mosaic-android (hardening per + Stage 1B findings; suites rerun green — Phase 7 owner condition). + `[work lists pending Stage 1B]` +- Stage 4: GA Drills 1–14 in an isolated Compose environment, evidence + recorded. +- Stage 5: documentation inventory above via owning agents. +- Stage 6: product/UX/protocol/quality read-only reviews; classified fix pass + (max two rounds); `docs/reviews/phase-8.md`; no merge; no `v1.0.0` tag. From 0f2b3a33d70106b57b91275361dcd1bfef6dab7e Mon Sep 17 00:00:00 2001 From: Muhideen Mujeeb Adeoye Date: Mon, 27 Jul 2026 08:28:32 +0100 Subject: [PATCH 03/35] docs: complete phase 8 plan with stage 1b audit reconciliation --- docs/plans/phase-8-operational-hardening.md | 74 ++++++++++++++++++--- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/docs/plans/phase-8-operational-hardening.md b/docs/plans/phase-8-operational-hardening.md index c496f61a..0d4f49c1 100644 --- a/docs/plans/phase-8-operational-hardening.md +++ b/docs/plans/phase-8-operational-hardening.md @@ -142,9 +142,16 @@ never implied as working. Owner-accepted limitations at planning time: - CPU architectures: amd64 and arm64 (Go cross-compiled, distroless base). - Dashboard browsers: per D7 above. - Go toolchain (build-from-source): 1.26.x. -- Flutter/Dart minimums: Flutter 3.19 / Dart 3.3 floor `[confirm in Stage 1B]`. -- iOS minimum, Swift, Xcode: `[pending Stage 1B]`. -- Android minimum API, Kotlin, Gradle, AGP: `[pending Stage 1B]`. +- Flutter/Dart minimums: Flutter 3.22 / Dart 3.4 (raised from the unverifiable + 3.19/3.3 claim; 3.22 is already the effective floor via the RevenueCat + adapter; stated as tested, not merely claimed). +- iOS minimum 15.0; Swift 6.0 language mode; Xcode 16.0 minimum. GA verified on + Xcode 26.5 / Swift 6.3.2; the iOS 15 slice is verified by a dedicated + deployment-target compile check (added in Stage 3 after fixing the + `ContinuousClock` regression). +- Android minimum API 24; compile/target SDK 36; Kotlin 2.2.10; Gradle 9.3.1; + AGP 9.1.1; JDK 17. Consumer-facing minimum host AGP/Kotlin documented in the + SDK README. - Protocol contracts at GA (all `approved`, exact-match readers): Paywall 0.2; Configuration Delivery 1, 2, 3; Commerce Provider 1, 2; Commerce Configuration 1, 2; Placement Decision 1; Analytics Event 1, 2; Experiment @@ -334,10 +341,18 @@ measured release-blocking issues are fixed; no speculative optimization. a new contract version; exact-match readers; negotiation selects the highest mutually supported representation; unknown versions rejected safely with last-known-valid retention. -- Deprecation: deprecated surface removed no earlier than two minor releases - and six months, migration guide at deprecation time; minimum SDK support - window 12 months / two minors; skip-version upgrades supported across one - minor. Final wording `[pending Stage 1B protocol report]`. +- Deprecation (contract-scoped): manifest lifecycle becomes + `draft → releaseCandidate → approved → deprecated → retired`, with `retired` + and an optional `deprecation` metadata block added to all manifest schemas + BEFORE the approval flip (one-way door). A contract version may not be + deprecated until its successor has been approved 6 months; deprecated + versions retire no sooner than a further 12 months (18-month minimum runway, + never fewer than two SDK minors); retired schemas/fixtures/docs stay in-tree + permanently. Breaking = a previously valid document rejected, field meaning + changed, or field/enum/capability removed; narrowing a schema to reject what + semantic validators already reject is non-breaking pre-approval only. +- Deprecation (SDK-scoped): each SDK minor supported 12 months; skip-version + upgrades across one minor; migration guide at deprecation time. ## Release-Artifact Policy and CI Gates @@ -431,10 +446,47 @@ the product still works. → WP12 → WP2/WP3 → WP1 → WP6/WP7/WP8 → WP5 → WP13; mosaic-dashboard (apps/dashboard, frontend docs/tests) WP1–WP7. Non-overlapping paths; neither touches SDKs or canonical protocol files. -- Stage 3 (write): mosaic-protocol (approval flip, policies, fixtures, - changelogs), mosaic-flutter, mosaic-ios, mosaic-android (hardening per - Stage 1B findings; suites rerun green — Phase 7 owner condition). - `[work lists pending Stage 1B]` +- Stage 3 (write), per Stage 1B audits and orchestrator rulings: + - mosaic-protocol: Phase A pre-flip widening (analytics manifest enum, all + manifest schemas gain `retired` + optional `deprecation` block, Delivery + v3 readerPolicy fallback keys, new Local Preview 0.2 manifest); encode + the analytics minimization rules in the canonical v1/v2 schemas + (per-event allow-lists via `unevaluatedProperties`, two + `dependentRequired` edits — fixture-neutral, verified); Phase D (D3 path + (a): correct the false `mosaic_*` claim, new export-names doc); Phase B + flip (12 manifests + the RC-status test assertion); Phase C/E policy and + changelog docs; Phase F fixture rejection-layer metadata (no file moves) + and analytics v2 negative fixtures. Browser-contract generation extension + deferred post-GA (tracked). Vocabulary note: `product_load` (Experiment + Assignment) and `product_loading` (Placement Decision) are intentionally + distinct — documented, no SDK change. + - mosaic-flutter: guard dispose/lifecycle/threshold flush and identity + persistence (B17 family) with the established safe-code pattern; + `decidePlacement` returns sealed unavailable on identity failure; version + reconciliation (wire constant = pubspec version); floor raise to + 3.22/3.4; CHANGELOG Phase 7 entry; installation docs per D6; format fix; + analytics v2 fixture scan; rewrite `docs/sdk/README.md` (sole owner, + folding in iOS/Android corrections). + - mosaic-ios: fix `ContinuousClock` → iOS 15-safe monotonic source and add + the iOS 15 typecheck command; `PrivacyInfo.xcprivacy` (SystemBootTime + reason 35F9.1, collected data, tracking=false); version reconciliation + (`mosaicSDKVersion` = podspec version); honest podspec source/install + docs; RevenueCat pin → `.upToNextMajor`; C4 precondition removals; C5 + configure degrades to in-memory + bundled fallback instead of throwing; + `.swift-format` pinned config + reformat; canonical assignment-vector + fixture binding; CHANGELOG cut; rerun example build via the seeded + package-cache workaround and the simulator suite. + - mosaic-android: replace reflective Gson with the explicit tree codec for + `MosaicCachedConfiguration` and `MosaicExperimentAssignmentRecord`; + correct `consumer-rules.pro`; enable `isMinifyEnabled` on the example + release build (R8 regression guard); Google Play delivery store off-main + + bounded + `noBackupFilesDir`; scroll-indicator recomposition fix; + analytics registry stale-flag fix; RevenueCat module publication parity + or README correction; version reconciliation (wire constant = artifact + version); CHANGELOG Phase 5–7 entries; honest install/R8 docs; cleartext + manifest scoping; canonical assignment-vector fixture binding. + - Cross-SDK rule: `Mosaic-SDK-Version` header equals the exact artifact + version on every platform. Dependency bumps are out of freeze scope. - Stage 4: GA Drills 1–14 in an isolated Compose environment, evidence recorded. - Stage 5: documentation inventory above via owning agents. From dd8b69886cd62593e8d4a11892fc3eeb2e8ca505 Mon Sep 17 00:00:00 2001 From: Muhideen Mujeeb Adeoye Date: Mon, 27 Jul 2026 10:48:05 +0100 Subject: [PATCH 04/35] feat: harden dashboard for v1 ga (stage 2) Organization switcher rewrite, route guards and sign-out, error classification and degraded states, app-wide field accessibility, error boundaries, pending states, focus management, browser matrix, runtime config injection, diagnostics panel, version stamping, dashboard Dockerfile and operations documentation. --- apps/dashboard/Dockerfile | 72 ++++++ apps/dashboard/Dockerfile.dockerignore | 23 ++ apps/dashboard/README.md | 101 ++++++++- apps/dashboard/package-lock.json | 6 +- apps/dashboard/package.json | 10 +- apps/dashboard/src/build-info.d.ts | 8 + .../feedback/app-error-boundary.tsx | 55 +++++ .../feedback/connectivity-banner.tsx | 36 +++ .../components/feedback/live-announcer.tsx | 27 +++ .../components/feedback/route-feedback.tsx | 72 +++++- .../src/components/layout/root-document.tsx | 10 + .../src/components/ui/field.test.tsx | 74 ++++++ apps/dashboard/src/components/ui/field.tsx | 90 +++++++- apps/dashboard/src/config/environment.test.ts | 52 +++++ apps/dashboard/src/config/environment.ts | 107 ++++++++- .../api-keys/components/api-keys-page.tsx | 99 +++++--- .../components/hosted-resource-boundary.tsx | 65 ++++-- .../features/auth/components/user-menu.tsx | 87 +++++++ .../auth/types/hosted-query-state.test.ts | 28 ++- .../features/auth/types/hosted-query-state.ts | 20 +- .../components/diagnostics-panel.tsx | 91 ++++++++ .../diagnostics/queries/api-health-query.ts | 30 +++ .../components/experiment-builder.tsx | 4 +- .../components/experiment-results.tsx | 51 ++++- .../components/experiment-workspace.tsx | 14 +- .../components/cloud-workspace-shell.tsx | 49 ++-- .../components/organization-switcher.test.tsx | 133 +++++++++++ .../components/organization-switcher.tsx | 214 +++++++++--------- .../components/canvas-preview-device.tsx | 6 +- .../canvas-preview-node-primitives.tsx | 7 +- .../components/canvas-preview-node.tsx | 25 +- .../components/component-tree-view.tsx | 40 ++-- .../components/preview-canvas.tsx | 4 + .../components/studio-command-palette.tsx | 13 ++ .../hooks/use-preview-connection.ts | 9 +- .../hosted-paywall-editor-route.tsx | 14 +- .../components/placement-decision-page.tsx | 18 +- .../components/qa-overrides.tsx | 11 +- .../placements/components/placements-page.tsx | 10 +- .../components/hosted-publish-panel.tsx | 117 +++++++--- .../rollback-release-action.test.tsx | 7 +- .../components/rollback-release-action.tsx | 34 ++- .../src/hooks/use-connectivity-status.ts | 60 +++++ .../src/hooks/use-organization-access.ts | 2 +- apps/dashboard/src/lib/api/errors.ts | 66 ++++++ .../dashboard/src/providers/app-providers.tsx | 13 +- apps/dashboard/src/routeTree.gen.ts | 21 ++ apps/dashboard/src/routes/__root.tsx | 7 + .../src/routes/_hosted.route-guard.test.ts | 101 +++++++++ apps/dashboard/src/routes/_hosted.tsx | 53 ++++- .../src/routes/_hosted/diagnostics.tsx | 21 ++ .../organizations/$organizationId/index.tsx | 3 + .../organizations/$organizationId/members.tsx | 3 + .../analytics/$environmentId/$surface.tsx | 3 + .../projects/$projectId/apps.tsx | 3 + .../$projectId/catalog/entitlements/index.tsx | 3 + .../$projectId/catalog/plans/index.tsx | 3 + .../$projectId/catalog/products/index.tsx | 3 + .../projects/$projectId/catalog/providers.tsx | 3 + .../projects/$projectId/index.tsx | 3 + .../monetization/$environmentId/assets.tsx | 3 + .../$environmentId/experiments.tsx | 4 +- .../monetization/$environmentId/paywalls.tsx | 3 + .../$environmentId/placements.tsx | 3 + .../monetization/$environmentId/releases.tsx | 3 + .../projects/$projectId/settings/api-keys.tsx | 3 + .../$projectId/settings/environments.tsx | 3 + .../src/routes/_hosted/workspace.tsx | 3 + apps/dashboard/src/routes/_studio_layout.tsx | 4 + apps/dashboard/src/styles/globals.css | 1 - apps/dashboard/vite.config.ts | 35 +++ apps/dashboard/vitest.config.ts | 5 + docs/architecture/conventions/frontend.md | 38 ++++ docs/dashboard/operations.md | 203 +++++++++++++++++ 74 files changed, 2309 insertions(+), 316 deletions(-) create mode 100644 apps/dashboard/Dockerfile create mode 100644 apps/dashboard/Dockerfile.dockerignore create mode 100644 apps/dashboard/src/build-info.d.ts create mode 100644 apps/dashboard/src/components/feedback/app-error-boundary.tsx create mode 100644 apps/dashboard/src/components/feedback/connectivity-banner.tsx create mode 100644 apps/dashboard/src/components/feedback/live-announcer.tsx create mode 100644 apps/dashboard/src/components/ui/field.test.tsx create mode 100644 apps/dashboard/src/config/environment.test.ts create mode 100644 apps/dashboard/src/features/auth/components/user-menu.tsx create mode 100644 apps/dashboard/src/features/diagnostics/components/diagnostics-panel.tsx create mode 100644 apps/dashboard/src/features/diagnostics/queries/api-health-query.ts create mode 100644 apps/dashboard/src/features/organizations/components/organization-switcher.test.tsx create mode 100644 apps/dashboard/src/hooks/use-connectivity-status.ts create mode 100644 apps/dashboard/src/routes/_hosted.route-guard.test.ts create mode 100644 apps/dashboard/src/routes/_hosted/diagnostics.tsx create mode 100644 docs/dashboard/operations.md diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile new file mode 100644 index 00000000..27408bd1 --- /dev/null +++ b/apps/dashboard/Dockerfile @@ -0,0 +1,72 @@ +# Mosaic dashboard image. +# +# Build context is the repository root, because the dashboard consumes the +# local `file:` packages under packages/. Paths are excluded through +# apps/dashboard/Dockerfile.dockerignore, which BuildKit prefers over the +# repository-root .dockerignore for this Dockerfile. +# +# docker build -f apps/dashboard/Dockerfile -t mosaic-dashboard:dev . +# +# The runtime serves the TanStack Start server on port 3000. + +FROM node:22.12-alpine AS build +ARG VERSION=dev +ARG COMMIT="" +ARG SOURCE_DATE_EPOCH="" +ENV MOSAIC_COMMIT=${COMMIT} \ + SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} +WORKDIR /src + +# Manifests first so dependency installation is cached independently of source. +COPY apps/dashboard/package.json apps/dashboard/package-lock.json ./apps/dashboard/ +COPY packages/design-system/package.json ./packages/design-system/ +COPY packages/design-tokens/package.json ./packages/design-tokens/ +# `npm ci` is deterministic: it installs exactly the locked tree or fails. +# --include=dev is explicit so the build toolchain is present regardless of the +# base image's NODE_ENV. +RUN cd apps/dashboard && npm ci --include=dev + +# The dashboard imports the generated protocol browser bundle directly. +COPY protocol/ ./protocol/ +COPY packages/ ./packages/ +COPY apps/dashboard/ ./apps/dashboard/ +# NODE_ENV must be production for this step: building with a development +# NODE_ENV emits the development JSX runtime, which then fails at runtime +# against production react-dom with "jsxDEV is not a function". +# Source maps are intentionally not emitted (see vite.config.ts). +RUN cd apps/dashboard && NODE_ENV=production npm run build + +# Reinstall production dependencies only, for the runtime layer. +RUN cd apps/dashboard && npm ci --omit=dev + +FROM node:22.12-alpine AS runtime +ARG VERSION=dev +ARG COMMIT="" +LABEL org.opencontainers.image.title="Mosaic Dashboard" \ + org.opencontainers.image.source="https://github.com/Mujhtech/mosaic" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${COMMIT}" + +ENV NODE_ENV=production \ + PORT=3000 \ + HOST=0.0.0.0 +WORKDIR /app + +COPY --from=build /src/apps/dashboard/dist ./dist +COPY --from=build /src/apps/dashboard/node_modules ./node_modules +COPY --from=build /src/apps/dashboard/package.json ./package.json +COPY --from=build /src/packages ../packages + +# The node image ships an unprivileged `node` user; the dashboard never needs +# to write to its own filesystem. +USER node +EXPOSE 3000 + +# The server answers GET / with the SSR shell; a 200 means the Node process and +# the built bundle are both healthy. It intentionally does not probe the Mosaic +# API: the dashboard must stay up and report API problems, not fail with them. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["npm", "run", "start"] diff --git a/apps/dashboard/Dockerfile.dockerignore b/apps/dashboard/Dockerfile.dockerignore new file mode 100644 index 00000000..e6b324d7 --- /dev/null +++ b/apps/dashboard/Dockerfile.dockerignore @@ -0,0 +1,23 @@ +# BuildKit prefers this file over the repository-root .dockerignore when +# building apps/dashboard/Dockerfile, so the dashboard can use the repository +# root as its context (it needs packages/) without the API's exclusions. +.git +.github +.vscode +.codex +.claude +**/.DS_Store + +apps/api/ +apps/worker/ +sdk/ +examples/ +deploy/ +docs/ +scripts/ + +**/node_modules/ +**/dist/ +**/.output/ +**/coverage/ +**/*.log diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index eb88aba7..89b482e1 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -12,9 +12,11 @@ Applications, Environments, environment-scoped API keys, memberships, and the pr Catalog. Hosted REST calls use the generated client under `src/generated/api`; regenerate it from the backend OpenAPI document after a contract change with `npm run generate:api`. -Hosted authentication remains an explicit owner-decision gate. The login and signup forms validate -locally with TanStack Form but do not create a browser session or simulate a signed-in user. -`/studio` remains account-free. +Hosted authentication is implemented. The login and signup forms in +`src/features/auth/components` validate with TanStack Form and call the Mosaic REST API, which +establishes an HttpOnly browser session cookie. `/_hosted` routes are guarded: an unauthenticated +visitor is redirected to `/login?returnTo=…` with a sanitized internal path, and the sidebar footer +provides sign-out. `/studio` remains account-free and needs no session. ## Analytics workspace @@ -45,6 +47,92 @@ payloads do not submit tenant or Application identifiers; trusted scope is deriv - Node.js 22.12 or newer - npm 10 or newer +## Supported browsers + +| Browser | Minimum | +| ------- | ------- | +| Chrome | 111 | +| Edge | 111 | +| Safari | 16.4 | +| Firefox | 128 | + +The floor is set by Tailwind CSS v4 (cascade layers and `@property`); older browsers render an +unusable layout rather than a degraded one. The matrix is encoded as `browserslist` in +`package.json`, so PostCSS and Tailwind target exactly these engines. No polyfills are shipped. + +**HTTPS or `localhost` is required.** The session cookie is `Secure`, and clipboard access used to +copy correlation identifiers is restricted to secure contexts. Serving the dashboard over plain +`http://` on any other host produces a sign-in loop. + +Viewport floors: Studio is **desktop-only** and requires at least 768 px of width, below which it +shows a desktop-required state that still allows a safe local export. The hosted workspace is +**desktop-first** — usable on a tablet, but its wide tables and panels are laid out for desktop +widths and it is not a supported phone experience. + +## Runtime configuration + +Configuration is read from the **server's** environment at render time and injected into the page as +`window.__MOSAIC_CONFIG__` before any application module executes. The shipped bundle contains no +baked-in API URL, so one image can be deployed against any API host without rebuilding. + +| Variable | Default | Meaning | +| ------------------------------------- | ----------------------------- | ------------------------------------------------------ | +| `MOSAIC_DASHBOARD_API_BASE_URL` | `http://localhost:8080` | Mosaic API origin. Absolute `http:`/`https:` URL. | +| `MOSAIC_DASHBOARD_PREVIEW_URL` | `ws://127.0.0.1:4317/preview` | Local Studio preview relay. Absolute `ws:`/`wss:` URL. | +| `MOSAIC_DASHBOARD_PREVIEW_SESSION_ID` | `session_local_01` | Preview session identifier. | +| `PORT` / `HOST` | `3000` / `0.0.0.0` | Listen address for the production server. | + +Values are validated in `src/config/environment.ts`. A missing, malformed, or wrong-protocol value +falls back to the documented default instead of throwing, so a misconfigured deployment still +renders a page that can explain the problem. Confirm the resolved values on `/diagnostics`. + +The build-time `VITE_API_BASE_URL`, `VITE_MOSAIC_PREVIEW_URL`, and `VITE_MOSAIC_PREVIEW_SESSION_ID` +remain **development** fallbacks only. These variables are dashboard-owned and are deliberately +absent from the backend-owned `.env.example`. + +## Deployment + +The dashboard ships as its own container image listening on **port 3000**: + +```bash +docker build \ + -f apps/dashboard/Dockerfile \ + --build-arg VERSION=1.0.0-rc.1 \ + --build-arg COMMIT="$(git rev-parse --short HEAD)" \ + -t mosaic-dashboard:1.0.0-rc.1 \ + . +``` + +The build context is the repository root, because the dashboard consumes the local `file:` packages +under `packages/` and the generated protocol bundle under `protocol/browser`. Exclusions come from +`apps/dashboard/Dockerfile.dockerignore`. The image runs as the unprivileged `node` user, sets +`NODE_ENV=production`, installs with `npm ci` for a deterministic tree, and exposes a `HEALTHCHECK` +that requests `/` from itself without probing the Mosaic API. + +Source maps are not emitted or shipped: they would publish Mosaic's client source to every visitor, +and Mosaic sends no client error reports anywhere that would consume them. + +Full operational detail, including troubleshooting for blank pages, 401 loops, CORS, missing +cookies, a wrong API URL, and the SSR cookie caveat, is in +[`docs/dashboard/operations.md`](../../docs/dashboard/operations.md). + +## Error-reporting policy + +Mosaic ships **no client-side error reporting** — no Sentry, no telemetry beacon, no automatic crash +upload. This is deliberate: an operator who self-hosts Mosaic must not have their users' browsing +silently forwarded to a third party, and Mosaic has no service to forward it to. + +What replaces it: + +- Every API failure carries an `X-Request-ID` correlation identifier, surfaced in error boundaries + and on `/diagnostics` with a copy control plus a selectable fallback for browsers without + clipboard access. +- User-facing copy never contains raw server messages or stack traces. `describeApiError` in + `src/lib/api/errors.ts` maps failures onto Mosaic-owned text; server detail stays in the API logs, + already correlated by the same identifier. +- `/diagnostics` reports the dashboard version, commit, build time, resolved API base URL, session + state, and an on-demand API liveness probe. + ## Setup ```bash @@ -140,9 +228,10 @@ Layers, Components, Products, or Localization. `F` fits the canvas, `Shift+0` re diagnostics panel. Global shortcuts pause while an input, textarea, select, contenteditable, or command search owns focus. -Set `VITE_API_BASE_URL` for the hosted REST workspace. The default is `http://localhost:8080`; the -generated client supplies the versioned `/v1/...` paths. Local Studio itself does not require the -API. +In development, `VITE_API_BASE_URL` still points the hosted REST workspace at an API; deployed +instances use `MOSAIC_DASHBOARD_API_BASE_URL` instead (see **Runtime configuration**). The default is +`http://localhost:8080`, and the generated client supplies the versioned `/v1/...` paths. Local +Studio itself does not require the API. ## Purchase setup diff --git a/apps/dashboard/package-lock.json b/apps/dashboard/package-lock.json index c2538f5c..283dacf8 100644 --- a/apps/dashboard/package-lock.json +++ b/apps/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mosaic/dashboard", - "version": "0.1.0", + "version": "1.0.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mosaic/dashboard", - "version": "0.1.0", + "version": "1.0.0-rc.1", "dependencies": { "@base-ui/react": "^1.6.0", "@mosaic/design-system": "file:../../packages/design-system", @@ -24,6 +24,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-resizable-panels": "^4.12.2", + "srvx": "^0.11.22", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "ws": "^8.21.1" @@ -48,7 +49,6 @@ "jsdom": "^29.1.1", "prettier": "^3.9.5", "prettier-plugin-tailwindcss": "^0.8.1", - "srvx": "^0.11.22", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "typescript-eslint": "^8.64.0", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 38a4d15b..782c1699 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,11 +1,17 @@ { "name": "@mosaic/dashboard", - "version": "0.1.0", + "version": "1.0.0-rc.1", "private": true, "type": "module", "sideEffects": [ "**/*.css" ], + "browserslist": [ + "chrome >= 111", + "edge >= 111", + "firefox >= 128", + "safari >= 16.4" + ], "engines": { "node": ">=22.12.0" }, @@ -44,6 +50,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-resizable-panels": "^4.12.2", + "srvx": "^0.11.22", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "ws": "^8.21.1" @@ -68,7 +75,6 @@ "jsdom": "^29.1.1", "prettier": "^3.9.5", "prettier-plugin-tailwindcss": "^0.8.1", - "srvx": "^0.11.22", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "typescript-eslint": "^8.64.0", diff --git a/apps/dashboard/src/build-info.d.ts b/apps/dashboard/src/build-info.d.ts new file mode 100644 index 00000000..2ebc5de2 --- /dev/null +++ b/apps/dashboard/src/build-info.d.ts @@ -0,0 +1,8 @@ +/** + * Build identity stamped by Vite `define` (see vite.config.ts). These make the + * running bundle self-identifying, which is what a deployment diagnosis needs + * when several dashboard versions may be in circulation. + */ +declare const __MOSAIC_VERSION__: string +declare const __MOSAIC_COMMIT__: string +declare const __MOSAIC_BUILD_TIME__: string diff --git a/apps/dashboard/src/components/feedback/app-error-boundary.tsx b/apps/dashboard/src/components/feedback/app-error-boundary.tsx new file mode 100644 index 00000000..8e2687d6 --- /dev/null +++ b/apps/dashboard/src/components/feedback/app-error-boundary.tsx @@ -0,0 +1,55 @@ +import * as React from "react" + +import { ErrorState } from "@/components/feedback/error-state" +import { describeApiError } from "@/lib/api/errors" + +interface AppErrorBoundaryState { + error: unknown +} + +/** + * Last-resort boundary mounted above the application providers. + * + * A render failure inside a provider (query client, tooltip portal, theme) + * escapes the router's route-level boundaries and would otherwise blank the + * page. Mosaic ships no client error reporting by design, so nothing is sent + * anywhere: the operator gets recovery actions and, when available, the + * correlation identifier to quote to whoever runs the API. + */ +export class AppErrorBoundary extends React.Component< + { children: React.ReactNode }, + AppErrorBoundaryState +> { + state: AppErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: unknown): AppErrorBoundaryState { + return { error } + } + + render() { + if (!this.state.error) return this.props.children + + const described = describeApiError(this.state.error) + + return ( +
+ { + this.setState({ error: null }) + }} + retryLabel="Try rendering again" + title="The dashboard could not be displayed" + /> + {described.correlationId ? ( +

+ Request ID:{" "} + + {described.correlationId} + +

+ ) : null} +
+ ) + } +} diff --git a/apps/dashboard/src/components/feedback/connectivity-banner.tsx b/apps/dashboard/src/components/feedback/connectivity-banner.tsx new file mode 100644 index 00000000..5295b70a --- /dev/null +++ b/apps/dashboard/src/components/feedback/connectivity-banner.tsx @@ -0,0 +1,36 @@ +import { WifiSlashIcon } from "@phosphor-icons/react/dist/ssr/WifiSlash" + +import { useConnectivityStatus } from "@/hooks/use-connectivity-status" + +/** + * Always-mounted status region. It stays in the tree so assistive technology + * announces the transition rather than a region appearing from nowhere. + */ +export function ConnectivityBanner() { + const { isDegraded, isOffline } = useConnectivityStatus() + const visible = isDegraded || isOffline + + return ( +
+ {visible ? ( + <> + + + {isOffline + ? "You are offline. Mosaic will keep showing the last loaded data and retry automatically." + : "Mosaic cannot reach the API. Displayed data may be stale; retries continue in the background."} + + + ) : null} +
+ ) +} diff --git a/apps/dashboard/src/components/feedback/live-announcer.tsx b/apps/dashboard/src/components/feedback/live-announcer.tsx new file mode 100644 index 00000000..1a25f0cf --- /dev/null +++ b/apps/dashboard/src/components/feedback/live-announcer.tsx @@ -0,0 +1,27 @@ +/** + * Always-mounted screen-reader announcement region. + * + * A live region that is mounted at the same moment its text appears is + * frequently missed by assistive technology. Mosaic keeps one empty region in + * the tree per surface and only changes its text, so every announcement is + * observed as a content change. + */ +export function LiveAnnouncer({ + assertive = false, + message, +}: { + assertive?: boolean + message?: string +}) { + return ( +
+ {message ?? ""} +
+ ) +} diff --git a/apps/dashboard/src/components/feedback/route-feedback.tsx b/apps/dashboard/src/components/feedback/route-feedback.tsx index 66036e24..3b3601d1 100644 --- a/apps/dashboard/src/components/feedback/route-feedback.tsx +++ b/apps/dashboard/src/components/feedback/route-feedback.tsx @@ -1,24 +1,76 @@ -import type { ErrorComponentProps } from "@tanstack/react-router" +import { Link, type ErrorComponentProps } from "@tanstack/react-router" import { EmptyState } from "@/components/feedback/empty-state" import { ErrorState } from "@/components/feedback/error-state" +import { LoadingState } from "@/components/feedback/loading-state" +import { Button } from "@/components/ui/button" +import { buttonVariants } from "@/components/ui/button-variants" +import { describeApiError } from "@/lib/api/errors" -export function RouteErrorState({ reset }: ErrorComponentProps) { +function RouteRecoveryActions() { return ( - +
+ + Go to workspace + + +
+ ) +} + +export function RouteErrorState({ error, reset }: ErrorComponentProps) { + // Stack traces and raw server messages are never rendered; operators get the + // correlation ID instead (client error reporting is off by design). + const described = describeApiError(error) + + return ( +
+ + {described.correlationId ? ( +

+ Request ID:{" "} + + {described.correlationId} + +

+ ) : null} + +
) } export function RouteNotFoundState() { return ( - + + + + ) +} + +export function RoutePendingState() { + return ( + ) } diff --git a/apps/dashboard/src/components/layout/root-document.tsx b/apps/dashboard/src/components/layout/root-document.tsx index b902f7e0..5f1837e8 100644 --- a/apps/dashboard/src/components/layout/root-document.tsx +++ b/apps/dashboard/src/components/layout/root-document.tsx @@ -1,6 +1,8 @@ import { HeadContent, Scripts } from "@tanstack/react-router" import type { ReactNode } from "react" +import { runtimeConfigScript } from "@/config/environment" + export function RootDocument({ children }: { children: ReactNode }) { return ( @@ -8,6 +10,14 @@ export function RootDocument({ children }: { children: ReactNode }) { + {/* Runtime configuration is assigned before the application modules at + the end of the body execute, so the same image works against any + API host. The value is resolved identically on the server and the + client, so hydration stays stable. */} +