Skip to content

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)

IDTermMeaning
RnFunctional requirementNumbered obligation (R1–R15 in this ADR).
NRnNon-functional requirementQuality attribute (NR1–NR7).
CnConstraintNon-negotiable boundary (C1–C8).
CCnCross-cutting challengeRisk spanning components (CC1–CC5).
PDS portalapps/pds-portalSubstratum-branded Mithril SPA on pds.* replacing Tranquil’s built-in web UI for customer-facing identity flows.
Tranquil upstreamOperated PDS APITranquil PDS on loopback :2583 — Postgres repo store, OAuth issuer, blobstore (ADR 41).
Identity UIBrowser surfaces on pds.*Sign-in, OAuth consent, password recovery, and account security pages — distinct from product UI on app.*.
Gateway-mediated signupExisting patternPOST /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 journeyUI todayBranded?
Create Substratum-login accountapp.*apps/file-explorer/Signup.tsxYes (ui-kit + gateway API)
Sign in to file explorer / adminapp.* / admin.* login → redirect to pds.*Partial (entry branded; PDS steps are Tranquil)
OAuth consent (“Authorize”)Tranquil HTML on pds.*No
Account security on PDS hostTranquil /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

IDRequirement
R1Substratum 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.
R2apps/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).
R3Phase 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.
R4Phase 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.
R5Signup 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.
R6Phase 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.
R7Phase 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.
R8The 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).
R9pds-authz-proxy entitlement gating for cloud.substratum.* repo mutations SHALL be unchanged; the portal does not implement entitlement logic.
R10apps/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/*.
R11Production 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.
R12Local 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.
R13E2E helpers (e.g. pds-oauth.page.ts) SHALL be updated to target portal data-testid / roles, not Tranquil-specific markup, once Phase 1 ships.
R14Tranquil [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.
R15Operator 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

IDRequirement
NR1Portal first paint and interaction SHALL match Substratum visual standards: design tokens, Button / TextInput / SurfaceCard, no raw Materialize chrome in app code (root AGENTS.md).
NR2Phase 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).
NR3Portal bundle SHALL be static-only at runtime (twelve-factor); no Node server on the PDS droplet for the SPA (ADR 13).
NR4Security: 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.
NR5License: Prefer a greenfield ui-kit implementation calling Tranquil HTTP/OAuth endpoints over vendoring or forking Tranquil’s AGPL frontend assets (pds-deployment.md).
NR6Portal releases MAY ship on a cadence independent of Tranquil image bumps when changes are UI-only; Tranquil image updates remain infra-owned.
NR7Accessibility: consent and sign-in flows SHALL meet the same bar as file-explorer login (labels, focus order, error announcements via ui-kit primitives).

Constraints

IDConstraint
C1OAuth authorization and PDS session establishment for third-party clients MUST occur on pds.* — not on app.*, admin.*, or support.*.
C2Garage v1 MUST keep Tranquil as the operated upstream PDS (ADR 41 R10); this ADR replaces UI only, not repo/OAuth server implementation.
C3apps/file-explorer remains the customer product host on app.*; the portal MUST NOT embed explorer routes or gateway session cookies (substratum_session).
C4apps/admin remains on admin.* with substratum_ops_session; the portal MUST NOT issue ops cookies or host entitlement mutation UI.
C5Direct 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).
C6Path routing MUST NOT break pds-authz-proxy gated XRPC (createRecord / putRecord on cloud.substratum.*) or receipt-sync worker OAuth (ADR 28).
C7Well-known OAuth metadata URLs on pds.* MUST remain fetchable by remote ATProto clients and the gateway (crates/auth/src/oauth.rs loopback rewrite behavior).
C8Do not add runtime micro-frontends or module federation (ADR 33).

Cross-cutting challenges

IDChallengeMitigation
CC1Tranquil OAuth/login may be server-rendered today; SPA replacement must map to stable HTTP endpointsSpike Phase 1 against Compose pds-upstream; document endpoint contract in apps/pds-portal/AGENTS.md; keep Tranquil fallback until parity proven.
CC2Caddy + proxy path split (SPA vs API) is easy to misconfigureDocument path table in pds-deployment.md; add verify-pds-live.sh check for portal index.html + OAuth metadata 200.
CC3Loopback 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.
CC4Phase 3 feature gap vs Tranquil (passkeys, 2FA)Explicit deferral (R7); link power users to fallback routes or schedule Phase 3 ADR revision when required.
CC5E2E and third-party OAuth clients depend on stable authorize UXPhase 1 exit criteria include Playwright auth specs + manual authorize smoke on app.* and admin.*.

Decision

1. Architecture

LayerResponsibility
Tranquil upstreamPDS repo, accounts, OAuth issuer, blobstore — unchanged (ADR 41).
pds-authz-proxyEntitlement gate on gated repo XRPC; transparent forward for all other upstream paths (apps/pds-authz-proxy/AGENTS.md).
apps/pds-portalBranded identity UI; static dist/ on PDS droplet.
CaddyTLS, path routing: portal static files vs proxy to authz.
app.* signupStays on file-explorer + gateway pds/signup (no portal signup in Phase 1).

2. Phased delivery

PhaseScopeGarage v1
1Sign-in + OAuth authorize/consent on pds.*; LanguagePicker + full locale catalogs (NR2)Required for branded login UX
2Password reset, email verify, basic account settingsOptional before launch
3Passkeys, 2FA, app passwords, delegation, repo browserDefer; Tranquil fallback or later ADR revision
Infraapps/pds-portal Nx project, CI build, PDS deploy wiringWith Phase 1

3. Package boundaries

Align with ADR 33 §8:

PackageRole
@substratum/ui-kitPresentational primitives only
@substratum/api-clientShared HTTP helpers where OpenAPI exists; PDS-specific calls may live in apps/pds-portal/src/services/ until spec’d
apps/pds-portalRoutes, pages (Login, OAuthAuthorize, …), Lingui catalogs, PDS-origin session helpers
apps/file-explorerUnchanged; redirects to pds.* for OAuth
apps/adminUnchanged; redirects to pds.* for staff OAuth

4. Implementation ownership

ArtifactLocation
Applicationapps/pds-portal/ (new) + apps/pds-portal/AGENTS.md
PDS edge / deployinfra/pds/, scripts/ci/deploy-pds-app.sh
Ops runbookpds-deployment.md (path routing + portal static root)
OAuth originsoauth-and-pds-origins.md
E2Eapps/file-explorer/e2e/pages/pds-oauth.page.ts → portal selectors

Rejected alternatives

AlternativeWhy rejected
CSS-only reskin of Tranquil frontendDoes not use ui-kit; forked AGPL assets; still Tranquil component model.
Replace Tranquil [frontend] dir with Vite build drop-inTranquil 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 iframeCookie/session friction, poor UX, ADR 33 rejected iframes.
Replace Tranquil with reference Bluesky PDSLoses 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 1Scope 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_pds Postgres schema unchanged.
  • Remote BYO PDS users never see the portal — only Substratum-login handles use pds.substratum.cloud.

Verification

ScenarioExpected
File explorer login (*.test dev handle)app.* login → pds.* portal sign-in → portal authorize → callback on app.* with substratum_session
Admin staff loginadmin.*pds.* portalsubstratum_ops_session on admin origin
Gateway signupPOST /api/v1/pds/signup on app.* still creates account; no portal signup required
Gated putRecord when lapsedStill 403 from authz proxy; portal unrelated
GET /.well-known/oauth-authorization-server on pds.*200; metadata matches Tranquil issuer
Compose dev loopbackApp at 127.0.0.1:8080, PDS portal at localhost:3000, OAuth succeeds
Playwright auth E2Epds-oauth.page.ts passes against portal markup
Portal LanguagePickerSwitches locale; all sign-in and authorize chrome updates without reload; substratum.locale persists across pds.* visits
Portal static deployverify-pds-live.sh (or successor) confirms portal index.html on pds.*