ADR 42: Branded PDS Portal (apps/pds-portal)
Status: Accepted
Date: 2026-06-11
Last Updated: 2026-06-11 (NR2 LanguagePicker from Phase 1)
Terms (this ADR)
| ID | Term | Meaning |
|---|---|---|
| Rn | Functional requirement | Numbered obligation (R1–R15 in this ADR). |
| NRn | Non-functional requirement | Quality attribute (NR1–NR7). |
| Cn | Constraint | Non-negotiable boundary (C1–C8). |
| CCn | Cross-cutting challenge | Risk spanning components (CC1–CC5). |
| PDS portal | apps/pds-portal | Substratum-branded Mithril SPA on pds.* replacing Tranquil’s built-in web UI for customer-facing identity flows. |
| Tranquil upstream | Operated PDS API | Tranquil PDS on loopback :2583 — Postgres repo store, OAuth issuer, blobstore (ADR 41). |
| Identity UI | Browser surfaces on pds.* | Sign-in, OAuth consent, password recovery, and account security pages — distinct from product UI on app.*. |
| Gateway-mediated signup | Existing pattern | POST /api/v1/pds/signup on app.* creates accounts via Tranquil XRPC without Tranquil signup HTML (pds_signup.rs). |
Canonical product vocabulary: Glossary.
Context
Substratum operates pds.substratum.cloud with Tranquil PDS as the upstream (ADR 41). Tranquil provides a scalable Postgres-backed PDS and a bundled web UI (sign-in, OAuth authorize, account settings, 2FA/passkeys, admin, repo browser). The server architecture fits Garage v1; the default Tranquil chrome does not match Substratum’s Midnight Gallery design language (@substratum/ui-kit).
Today:
| User journey | UI today | Branded? |
|---|---|---|
| Create Substratum-login account | app.* → apps/file-explorer/Signup.tsx | Yes (ui-kit + gateway API) |
| Sign in to file explorer / admin | app.* / admin.* login → redirect to pds.* | Partial (entry branded; PDS steps are Tranquil) |
| OAuth consent (“Authorize”) | Tranquil HTML on pds.* | No |
| Account security on PDS host | Tranquil /app/* | No |
AT Protocol OAuth requires the user to authenticate and consent on the PDS origin (oauth-and-pds-origins.md). That rules out folding identity UI into apps/file-explorer (ADR 33 §7). It does not rule out a sibling SPA on pds.* — the same pattern as apps/admin on admin.* and apps/landing on marketing.
Goal: Keep Tranquil as the API upstream; ship apps/pds-portal as the customer-facing identity UI on the operated PDS hostname.
Requirements
Functional requirements
| ID | Requirement |
|---|---|
| R1 | Substratum SHALL add apps/pds-portal: a Vite + Mithril SPA using @substratum/ui-kit, Lingui (ADR 14), and the same Nx workspace conventions as apps/admin and apps/file-explorer. |
| R2 | apps/pds-portal SHALL be deployed at the operated PDS public origin (pds.substratum.cloud in production; http://localhost:3000 in Compose dev behind pds-authz-proxy). |
| R3 | Phase 1 (Garage v1 UX gate): The portal SHALL replace Tranquil HTML for PDS sign-in and OAuth authorization consent — the flows exercised by apps/file-explorer and apps/admin after oauth/start / ops/oauth/start. |
| R4 | Phase 1 SHALL preserve existing OAuth semantics: issuer metadata, /.well-known/oauth-authorization-server, DPoP, redirect URLs, and loopback Sec-Fetch-Site pairing (127.0.0.1:8080 app vs localhost:3000 PDS) documented in oauth-and-pds-origins.md. |
| R5 | Signup for Substratum-login handles SHALL remain gateway-mediated on app.* (POST /api/v1/pds/signup); the portal MUST NOT become the primary open signup surface while garage-v1-rollout.md blocks direct PDS signup. |
| R6 | Phase 2: The portal MAY add account recovery and security pages (password change, email verification) needed for operated PDS accounts without exposing Tranquil chrome to end users. |
| R7 | Phase 3 (optional / deferred): Advanced Tranquil features (WebAuthn/passkeys, TOTP 2FA, app passwords, delegation, repo browser, PDS admin) MAY remain on Tranquil fallback routes or be reimplemented in the portal when product requires them — not required for Garage v1 launch. |
| R8 | The PDS edge SHALL serve portal static assets for browser document navigations on agreed path prefixes; XRPC, OAuth protocol, and well-known endpoints SHALL continue to reach Tranquil via substratum-pds-authz-proxy (ADR 37). |
| R9 | pds-authz-proxy entitlement gating for cloud.substratum.* repo mutations SHALL be unchanged; the portal does not implement entitlement logic. |
| R10 | apps/pds-portal MUST NOT import from apps/file-explorer/src or apps/admin/src (ADR 33 §8). Shared code lives in @substratum/ui-kit, @substratum/api-client, or future libs/*. |
| R11 | Production deploy SHALL follow the sibling-app pattern: build dist/, rsync or equivalent to the PDS droplet, Caddy file_server or reverse_proxy rules documented in infra/pds/ and pds-deployment.md. |
| R12 | Local dev SHALL expose the portal on the same host port as today’s public PDS URL (localhost:3000 via Compose) so existing E2E and OAuth docs remain valid at the origin level. |
| R13 | E2E helpers (e.g. pds-oauth.page.ts) SHALL be updated to target portal data-testid / roles, not Tranquil-specific markup, once Phase 1 ships. |
| R14 | Tranquil [frontend] assets MAY remain on disk for operator fallback (R7) but SHALL NOT be the default document handler for Phase 1 paths once the portal is live. |
| R15 | Operator staff flows that OAuth through pds.* (admin company SSO, ADR 38) SHALL use the same portal identity UI in Phase 1 — no separate Tranquil skin for staff. |
Non-functional requirements
| ID | Requirement |
|---|---|
| NR1 | Portal first paint and interaction SHALL match Substratum visual standards: design tokens, Button / TextInput / SurfaceCard, no raw Materialize chrome in app code (root AGENTS.md). |
| NR2 | Phase 1 SHALL ship internationalization matching sibling apps (ADR 14): Lingui with explicit IDs (message-ids.ts, hand-maintained messages.json per locale, compile-locales before build); the same shipped locale set as apps/file-explorer, apps/admin, and apps/landing (en, es, fr, de, pt-BR, fil); and @substratum/ui-kit LanguagePicker on sign-in and OAuth consent pages. Locale selection SHALL read/write substratum.locale in localStorage, call dynamicActivate, and re-render all portal copy without a full page reload — same behavior as file-explorer shell / installer-gui header (LanguagePicker, shell-service.ts). ui-kit receives plain string props only (e.g. shared shell.languagePickerAria Lingui id where applicable). |
| NR3 | Portal bundle SHALL be static-only at runtime (twelve-factor); no Node server on the PDS droplet for the SPA (ADR 13). |
| NR4 | Security: portal pages that collect credentials SHALL be served only over HTTPS in production; MUST NOT log passwords or OAuth codes; follow Tranquil upstream CSRF/session cookie rules for form posts. |
| NR5 | License: Prefer a greenfield ui-kit implementation calling Tranquil HTTP/OAuth endpoints over vendoring or forking Tranquil’s AGPL frontend assets (pds-deployment.md). |
| NR6 | Portal releases MAY ship on a cadence independent of Tranquil image bumps when changes are UI-only; Tranquil image updates remain infra-owned. |
| NR7 | Accessibility: consent and sign-in flows SHALL meet the same bar as file-explorer login (labels, focus order, error announcements via ui-kit primitives). |
Constraints
| ID | Constraint |
|---|---|
| C1 | OAuth authorization and PDS session establishment for third-party clients MUST occur on pds.* — not on app.*, admin.*, or support.*. |
| C2 | Garage v1 MUST keep Tranquil as the operated upstream PDS (ADR 41 R10); this ADR replaces UI only, not repo/OAuth server implementation. |
| C3 | apps/file-explorer remains the customer product host on app.*; the portal MUST NOT embed explorer routes or gateway session cookies (substratum_session). |
| C4 | apps/admin remains on admin.* with substratum_ops_session; the portal MUST NOT issue ops cookies or host entitlement mutation UI. |
| C5 | Direct com.atproto.server.createAccount on the public PDS edge MUST stay disabled or invite-gated per rollout policy; signup bypasses gateway entitlement story (garage-v1-rollout.md). |
| C6 | Path routing MUST NOT break pds-authz-proxy gated XRPC (createRecord / putRecord on cloud.substratum.*) or receipt-sync worker OAuth (ADR 28). |
| C7 | Well-known OAuth metadata URLs on pds.* MUST remain fetchable by remote ATProto clients and the gateway (crates/auth/src/oauth.rs loopback rewrite behavior). |
| C8 | Do not add runtime micro-frontends or module federation (ADR 33). |
Cross-cutting challenges
| ID | Challenge | Mitigation |
|---|---|---|
| CC1 | Tranquil OAuth/login may be server-rendered today; SPA replacement must map to stable HTTP endpoints | Spike Phase 1 against Compose pds-upstream; document endpoint contract in apps/pds-portal/AGENTS.md; keep Tranquil fallback until parity proven. |
| CC2 | Caddy + proxy path split (SPA vs API) is easy to misconfigure | Document path table in pds-deployment.md; add verify-pds-live.sh check for portal index.html + OAuth metadata 200. |
| CC3 | Loopback dev two-origin rules (127.0.0.1 vs localhost) | Reuse existing gateway rewrite helpers; portal dev server binds localhost:3000 only; document in portal AGENTS.md and oauth-and-pds-origins.md. |
| CC4 | Phase 3 feature gap vs Tranquil (passkeys, 2FA) | Explicit deferral (R7); link power users to fallback routes or schedule Phase 3 ADR revision when required. |
| CC5 | E2E and third-party OAuth clients depend on stable authorize UX | Phase 1 exit criteria include Playwright auth specs + manual authorize smoke on app.* and admin.*. |
Decision
1. Architecture
| Layer | Responsibility |
|---|---|
| Tranquil upstream | PDS repo, accounts, OAuth issuer, blobstore — unchanged (ADR 41). |
pds-authz-proxy | Entitlement gate on gated repo XRPC; transparent forward for all other upstream paths (apps/pds-authz-proxy/AGENTS.md). |
apps/pds-portal | Branded identity UI; static dist/ on PDS droplet. |
| Caddy | TLS, path routing: portal static files vs proxy to authz. |
app.* signup | Stays on file-explorer + gateway pds/signup (no portal signup in Phase 1). |
2. Phased delivery
| Phase | Scope | Garage v1 |
|---|---|---|
| 1 | Sign-in + OAuth authorize/consent on pds.*; LanguagePicker + full locale catalogs (NR2) | Required for branded login UX |
| 2 | Password reset, email verify, basic account settings | Optional before launch |
| 3 | Passkeys, 2FA, app passwords, delegation, repo browser | Defer; Tranquil fallback or later ADR revision |
| Infra | apps/pds-portal Nx project, CI build, PDS deploy wiring | With Phase 1 |
3. Package boundaries
Align with ADR 33 §8:
| Package | Role |
|---|---|
@substratum/ui-kit | Presentational primitives only |
@substratum/api-client | Shared HTTP helpers where OpenAPI exists; PDS-specific calls may live in apps/pds-portal/src/services/ until spec’d |
apps/pds-portal | Routes, pages (Login, OAuthAuthorize, …), Lingui catalogs, PDS-origin session helpers |
apps/file-explorer | Unchanged; redirects to pds.* for OAuth |
apps/admin | Unchanged; redirects to pds.* for staff OAuth |
4. Implementation ownership
| Artifact | Location |
|---|---|
| Application | apps/pds-portal/ (new) + apps/pds-portal/AGENTS.md |
| PDS edge / deploy | infra/pds/, scripts/ci/deploy-pds-app.sh |
| Ops runbook | pds-deployment.md (path routing + portal static root) |
| OAuth origins | oauth-and-pds-origins.md |
| E2E | apps/file-explorer/e2e/pages/pds-oauth.page.ts → portal selectors |
Rejected alternatives
| Alternative | Why rejected |
|---|---|
| CSS-only reskin of Tranquil frontend | Does not use ui-kit; forked AGPL assets; still Tranquil component model. |
Replace Tranquil [frontend] dir with Vite build drop-in | Tranquil UI is coupled to server routes; not a interchangeable static tree. |
Host OAuth consent on app.* | Violates AT Protocol PDS-origin consent (C1); breaks federated client expectations. |
| Embed PDS login in file-explorer iframe | Cookie/session friction, poor UX, ADR 33 rejected iframes. |
| Replace Tranquil with reference Bluesky PDS | Loses Postgres repo store and Tranquil ops features (ADR 41). |
Single SPA across app.* and pds.* | Two origins, two cookies, two deploy artifacts — violates OAuth and deployment model. |
| Implement full Tranquil feature parity in Phase 1 | Scope explosion; defer advanced security to Phase 3 (R7). |
Consequences
Positive
- Customer and staff see Substratum-branded sign-in and OAuth consent while keeping Tranquil’s server architecture.
- Reuses established sibling-app + ui-kit pattern (
landing,admin) without bloating file-explorer. - Signup already branded on
app.*; Phase 1 closes the largest remaining UX gap. - Greenfield portal avoids AGPL frontend fork obligations (NR5).
Negative
- New deployable to build, host, and test on the PDS droplet.
- Phase 1–2 may lag Tranquil on passkeys/2FA until Phase 3 or fallback.
- Caddy path routing adds operational complexity (CC2).
Neutral
- Tranquil image and
tranquil_pdsPostgres schema unchanged. - Remote BYO PDS users never see the portal — only Substratum-login handles use
pds.substratum.cloud.
Verification
| Scenario | Expected |
|---|---|
File explorer login (*.test dev handle) | app.* login → pds.* portal sign-in → portal authorize → callback on app.* with substratum_session |
| Admin staff login | admin.* → pds.* portal → substratum_ops_session on admin origin |
| Gateway signup | POST /api/v1/pds/signup on app.* still creates account; no portal signup required |
Gated putRecord when lapsed | Still 403 from authz proxy; portal unrelated |
GET /.well-known/oauth-authorization-server on pds.* | 200; metadata matches Tranquil issuer |
| Compose dev loopback | App at 127.0.0.1:8080, PDS portal at localhost:3000, OAuth succeeds |
| Playwright auth E2E | pds-oauth.page.ts passes against portal markup |
Portal LanguagePicker | Switches locale; all sign-in and authorize chrome updates without reload; substratum.locale persists across pds.* visits |
| Portal static deploy | verify-pds-live.sh (or successor) confirms portal index.html on pds.* |
Related
- ADR 14: Frontend internationalization — Lingui, locale set,
LanguagePicker - ADR 33: Frontend modular monolith and package boundaries
- ADR 37: PDS entitlement proxy
- ADR 38: Support and entitlement admin tooling
- ADR 41: Operated PDS hosting topology
- OAuth and PDS origins
- PDS deployment
- Garage v1 rollout
- Glossary