From 269da3b5897c708810375ab8b416862836dd9bce Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 20 Jun 2026 16:02:02 -0500 Subject: [PATCH] feat: browser account login flow * add documentation viewer reference docs & update CHANGELOG --- CHANGELOG.md | 39 +-- assets/css/components/shell.css | 163 ++++++++++++- config/runtime.exs | 4 + docs/reference/README.md | 1 + docs/reference/doc-viewer.md | 114 +++++++++ docs/reference/public-stats-dashboard.md | 98 ++++++-- docs/specs/doc-viewer.md | 222 +----------------- docs/specs/public-stats-dashboard.md | 142 +---------- docs/tasks/14-local-pds-compatibility.md | 53 +---- docs/tasks/15-deployment-verification.md | 52 ++-- docs/tasks/16-public-stats-dashboard.md | 83 +------ docs/tasks/17-doc-viewer.md | 65 +---- docs/tasks/18-account-management.md | 22 +- lib/tempest/accounts.ex | 56 +++++ lib/tempest/admin.ex | 12 +- lib/tempest/admin_auth.ex | 66 +++++- lib/tempest/config.ex | 27 ++- lib/tempest/docs.ex | 1 + .../controllers/account_session_controller.ex | 75 ++++++ .../controllers/account_session_html.ex | 5 + .../account_session_html/new.html.heex | 53 +++++ .../controllers/admin_controller.ex | 22 +- .../controllers/admin_session_controller.ex | 111 +++++++++ .../controllers/admin_session_html.ex | 5 + .../admin_session_html/new.html.heex | 55 +++++ .../operator_account_controller.ex | 7 + lib/tempest_web/live/account_control_live.ex | 130 ++++++++++ lib/tempest_web/live/admin_control_live.ex | 66 ++++++ lib/tempest_web/live/control_panel_auth.ex | 67 ++++++ lib/tempest_web/plugs/account_browser_auth.ex | 81 +++++++ lib/tempest_web/plugs/admin_browser_auth.ex | 73 ++++++ lib/tempest_web/router.ex | 66 ++++-- priv/static/images/icons/lock-key.svg | 13 + test/tempest/config_test.exs | 12 + .../controllers/admin_controller_test.exs | 162 ++++++++++++- .../operator_account_controller_test.exs | 72 +++++- 36 files changed, 1642 insertions(+), 653 deletions(-) create mode 100644 docs/reference/doc-viewer.md create mode 100644 lib/tempest_web/controllers/account_session_controller.ex create mode 100644 lib/tempest_web/controllers/account_session_html.ex create mode 100644 lib/tempest_web/controllers/account_session_html/new.html.heex create mode 100644 lib/tempest_web/controllers/admin_session_controller.ex create mode 100644 lib/tempest_web/controllers/admin_session_html.ex create mode 100644 lib/tempest_web/controllers/admin_session_html/new.html.heex create mode 100644 lib/tempest_web/live/account_control_live.ex create mode 100644 lib/tempest_web/live/admin_control_live.ex create mode 100644 lib/tempest_web/live/control_panel_auth.ex create mode 100644 lib/tempest_web/plugs/account_browser_auth.ex create mode 100644 lib/tempest_web/plugs/admin_browser_auth.ex create mode 100644 priv/static/images/icons/lock-key.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5ca26..338dd4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,22 +6,29 @@ - Added OAuth client metadata validation for the public-client path +### 2026-06-14 + +- Expanded the public stats dashboard with user cards, profile image blobs, + latest indexed record details, weekly commit activity, and collection + summaries. +- Added the `/changelog` pageoute for `CHANGELOG.md` rendering and a desktop shortcut. +- Added the `/docs`/reference viewer with Markdown rendering, safe manifest lookup, + relative reference-link rewriting, and a retro browser-style interface. + ### 2026-06-13 -- Added deployment verification support for post-deployment checks, including release - and deployment docs, HTTPS smoke runbook, and hosted DID/relay/AppView verification - guidance. +- Added deployment support for release builds, deployment docs, HTTPS operations, + and hosted DID, relay, and AppView readiness. - Added public aggregate stats surfaces: - `GET /stats` HTML dashboard. - `GET /xrpc/_stats` public JSON snapshot with generated timestamps. - - Public stats privacy checks and smoke coverage for unauthorized-sensitive data. ### 2026-06-12 -- Added local PDS compatibility verification for protocol endpoints, auth matrices, - black-box flows, and event compatibility against local smoke profiles. -- Added restore-drill coverage and local AppView proxy/fallback verification to keep - compatibility expectations aligned before deployment. +- Added local PDS compatibility support for protocol endpoints, auth matrices, + public HTTP flows, and event compatibility. +- Added restore drills and a local AppView proxy/fallback policy to keep + compatibility behavior aligned before deployment. ### 2026-06-03 @@ -33,15 +40,15 @@ ### 2026-05-31 -- Added compatibility smoke coverage for `applyWrites`, `getBlocks`, `requestCrawl`, +- Added compatibility behavior for `applyWrites`, `getBlocks`, `requestCrawl`, preference endpoints, and unknown AppView fallback. - Added account security foundations for email tokens, security events, TOTP MFA, backup codes, delegated-access grants, session revocation, and auth rate limits. - Added hosted DID mode configuration, PLC publish boundary, and identity correctness - smoke coverage. + protections. - Added migration lifecycle support for service auth, imported DID account creation, repo CAR import, missing-blob listing, activation/deactivation, account deletion, - migration-safe event ordering, and smoke coverage. + and migration-safe event ordering. ### 2026-05-08 @@ -51,8 +58,8 @@ - Initialized an empty repository when creating an account. - Added record writes and reads through `com.atproto.repo.createRecord`, `putRecord`, `deleteRecord`, `getRecord`, `listRecords`, and `describeRepo`. -- Added record validation boundaries, duplicate-rkey handling, swap checks, pagination, - and restart persistence coverage. +- Added record validation boundaries, duplicate-rkey handling, swap handling, + pagination, and restart persistence. - Added sync read support for `getRepo`, `getLatestCommit`, `getRecord`, `getBlocks`, `getRepoStatus`, `listRepos`, and `listBlobs`. - Set CAR responses to `Content-Type: application/vnd.ipld.car`. @@ -68,7 +75,7 @@ endpoints, and bearer auth. - Added DID and handle validators, signing keys, DID document generation, hosted DID discovery, handle resolution, handle update, and SSRF protection for remote handle - checks. -- Added Hurl smoke test scaffolding. + resolution. +- Added Hurl-based compatibility tooling. - Added baseline identity and handle local behavior for did:web and did:plc setup. -- Added hosted DID mode config, public DID publishing, and handle verification flow checks. +- Added hosted DID mode config, public DID publishing, and handle verification flows. diff --git a/assets/css/components/shell.css b/assets/css/components/shell.css index 4a067ed..d387943 100644 --- a/assets/css/components/shell.css +++ b/assets/css/components/shell.css @@ -1,5 +1,6 @@ .tempest-home, -.operator-account { +.operator-account, +.account-login { min-height: 100vh; padding: clamp(0.75rem, 2vw, 1.35rem); background-color: var(--win-desktop); @@ -35,6 +36,166 @@ width: min(74rem, 100%); } +.account-login { + display: grid; + place-items: center; +} + +.account-login__dialog { + width: min(34rem, calc(100vw - 1.5rem)); +} + +.account-login__body { + display: grid; + grid-template-columns: 3.5rem minmax(0, 1fr); + gap: var(--space-4); + align-items: start; + min-height: 12rem; +} + +.account-login__icon { + width: 3rem; + height: 3rem; + image-rendering: pixelated; +} + +.account-login__content, +.account-login__form { + display: grid; + gap: var(--space-3); +} + +.account-login__prompt, +.account-login__error, +.account-login__meta { + margin: 0; + font-size: var(--step--1); + line-height: var(--leading-copy); +} + +.account-login__error { + padding: var(--space-2); + color: var(--danger); + background: var(--surface-sunken); + box-shadow: var(--border-in); +} + +.account-login__form .form-field { + margin-block-end: 0; +} + +.account-login__field { + display: grid; + grid-template-columns: 1.45rem minmax(0, 1fr); + gap: var(--space-2); + align-items: center; +} + +.account-login__field > img { + width: 1.15rem; + height: 1.15rem; + image-rendering: pixelated; +} + +.account-login__form label { + display: grid; + grid-template-columns: 5.2rem minmax(0, 1fr); + gap: var(--space-3); + align-items: center; +} + +.account-login__form .form-label { + font-weight: 400; + text-decoration: underline; + text-underline-offset: 0.1em; +} + +.account-login__input { + min-height: 1.75rem; + border: 0; + border-radius: 0; + background: white; + box-shadow: var(--border-in); + font: inherit; +} + +.account-login__meta { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: var(--space-1) var(--space-2); + padding: var(--space-2); + background: var(--surface-sunken); + box-shadow: var(--border-in); +} + +.account-login__meta dt { + font-weight: 700; +} + +.account-login__meta dd { + margin: 0; + font-family: var(--font-mono); + overflow-wrap: anywhere; +} + +.account-login__actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding-block-start: var(--space-1); +} + +.win-button, +.win-button:visited { + display: inline-grid; + place-items: center; + min-width: 5rem; + min-height: 1.75rem; + padding: 0.18rem var(--space-3); + border: 0; + color: var(--text); + background: var(--surface); + box-shadow: var(--border-out); + font: inherit; + font-size: var(--step--1); + line-height: 1; + text-decoration: none; + cursor: default; +} + +.win-button:hover, +.win-button:focus-visible { + color: var(--text); + background: var(--surface-raised); +} + +.win-button:active { + box-shadow: var(--border-in); +} + +@media (max-width: 34rem) { + .account-login__body { + grid-template-columns: 1fr; + } + + .account-login__form label { + grid-template-columns: 1fr; + gap: var(--space-1); + } + + .account-login__field { + grid-template-columns: 1.25rem minmax(0, 1fr); + } + + .account-login__actions { + justify-content: stretch; + } + + .account-login__actions > * { + flex: 1; + } +} + .tempest-home__top-grid { display: grid; gap: var(--space-4); diff --git a/config/runtime.exs b/config/runtime.exs index 8540cf7..153f718 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -42,6 +42,10 @@ if hosted_did_method = System.get_env("TEMPEST_HOSTED_DID_METHOD") do config :tempest, Tempest.Config, hosted_did_method: String.to_existing_atom(hosted_did_method) end +if admin_did = System.get_env("TEMPEST_ADMIN_DID") do + config :tempest, Tempest.Config, admin_did: admin_did +end + identity_runtime_config = [] identity_runtime_config = diff --git a/docs/reference/README.md b/docs/reference/README.md index a6f7a11..ac5f936 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -29,6 +29,7 @@ Graduated reference docs: - [Interop and Integration Testing](./interop-testing.md) - [Identity and Handles](./identity-handles.md) - [Public Stats Dashboard](./public-stats-dashboard.md) +- [Documentation Viewer](./doc-viewer.md) --- diff --git a/docs/reference/doc-viewer.md b/docs/reference/doc-viewer.md new file mode 100644 index 0000000..7fec53a --- /dev/null +++ b/docs/reference/doc-viewer.md @@ -0,0 +1,114 @@ +--- +title: Documentation Viewer +updated: 2026-06-19 +--- + +Tempest publishes the project reference documentation as a public, browsable +Phoenix LiveView. The source of truth remains Markdown under `docs/reference/`; +the web UI is a deliberately constrained reader with a Web 1.0 / Netscape +Navigator-inspired interface. + +## Public routes + +```text +GET /docs +GET /docs/:slug +``` + +`/docs` renders the reference index from `docs/reference/README.md`. Other routes +map only to slugs in the fixed `Tempest.Docs` manifest, such as: + +```text +/docs/architecture +/docs/storage-sqlite +/docs/pds-compatibility +``` + +Unknown slugs return 404. Path traversal attempts and raw file names such as +`architecture.md` are rejected before any file read. + +## Manifest boundary + +`Tempest.Docs` owns a fixed manifest of publishable reference files. The route +parameter is a slug, not a path. A document is public only if it appears in that +manifest. + +The manifest also defines sidebar order and previous/next navigation. It includes +every Markdown file under `docs/reference/`, and the test suite compares the +manifest to the directory contents so new reference docs must be deliberately +published. + +## Rendering model + +Reference Markdown is trusted maintainer-authored project content. Tempest renders +it server-side with MDEx and supports headings, links, fenced code blocks, tables, +lists, inline code, and blockquotes. + +Each document may include simple frontmatter: + +```yaml +--- +title: Architecture +updated: 2026-06-03 +--- +``` + +If frontmatter is missing, the viewer falls back to manifest metadata. + +Only trusted local reference files are rendered. The viewer never renders +request-provided Markdown and never accepts arbitrary document paths. + +## Link rewriting + +Relative links between known reference docs are rewritten to viewer routes: + +```text +./architecture.md -> /docs/architecture +../reference/architecture.md -> /docs/architecture +identity-troubleshooting.md -> /docs/identity-troubleshooting +``` + +External `http://` and `https://` links remain external. Links to specs, tasks, +or files outside `docs/reference/` are not part of the public docs viewer +contract. + +## Interface + +The UI uses the existing vanilla CSS bundle and the dedicated +`assets/css/components/doc-viewer.css` component stylesheet. It is semantic HTML +with a retro browser shell: + +- title bar and beveled toolbar; +- Back, Forward, Stop, Reload, Home, Search, and Print controls; +- `Location:` path display; +- left bookmarks pane; +- main document pane; +- previous/next document links; +- footer copy for the Tempest Navigator motif. + +The page remains usable without JavaScript. The visual treatment references old +browser chrome, but it does not use real framesets or inline scripts. + +## Caching + +Docs are static project files. The current policy is: + +- `dev`: read every request for fast documentation iteration; +- `test`: read every request; +- `prod`: cache successfully rendered manifest documents in `:persistent_term`. + +Route params that are not in the manifest are never cached. + +## Verification + +```bash +curl -fsS http://localhost:4000/docs +curl -fsS http://localhost:4000/docs/architecture +hurl --test --jobs 1 \ + --variable base_url=http://localhost:4000 \ + test/smoke/doc-viewer.hurl +``` + +Focused tests cover unauthenticated rendering, sidebar content, relative-link +rewriting, path traversal rejection, unknown slugs, sample Markdown features, and +the guarantee that files outside `docs/reference/` cannot be rendered. diff --git a/docs/reference/public-stats-dashboard.md b/docs/reference/public-stats-dashboard.md index 8357421..0759f43 100644 --- a/docs/reference/public-stats-dashboard.md +++ b/docs/reference/public-stats-dashboard.md @@ -1,19 +1,21 @@ --- title: Public Stats Dashboard -updated: 2026-06-13 +updated: 2026-06-14 --- Tempest exposes aggregate operational data through public routes with no auth. The -dashboard is intended to make a deployment inspectable without exposing sensitive -admin or account details. +dashboard makes a deployment inspectable without exposing sensitive admin or +account details. ## Endpoints - `GET /stats` - `GET /xrpc/_stats` +- `GET /changelog` `/stats` is a readable HTML page linked from the homepage. `/xrpc/_stats` is the stable machine endpoint for scripts, external checks, and deployment smoke tests. +`/changelog` renders `CHANGELOG.md` as a separate public desktop document. ## Runtime data model @@ -22,7 +24,9 @@ The HTML and JSON views share a single source of truth: - no auth required for read access; - no caching in the first implementation (fresh aggregation per request); - `generatedAt` is always included and always reflects the snapshot time used for the - response. + response; +- bounded detail groups are derived from the same sanitized public stats boundary, + not from private admin status data in the view layer. ## `/xrpc/_stats` response contract @@ -51,7 +55,28 @@ The HTML and JSON views share a single source of truth: "sequencerReadable": true, "tornWriteCount": 0 } - } + }, + "users": [ + { + "did": "did:plc:example", + "handle": "alice.example.com", + "status": "active", + "recordCount": 42, + "lastIndexedAt": "2026-06-14T18:57:11Z", + "avatarUrl": "/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aexample&cid=baf...", + "bannerUrl": "/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aexample&cid=baf..." + } + ], + "latestRecord": { + "did": "did:plc:example", + "handle": "alice.example.com", + "collection": "app.bsky.feed.post", + "rkey": "3k...", + "cid": "baf...", + "indexedAt": "2026-06-14T18:57:11Z" + }, + "commitWeeks": [{ "weekStart": "2026-06-08", "weekEnd": "2026-06-14", "commitCount": 31 }], + "collections": [{ "collection": "app.bsky.feed.post", "recordCount": 120 }] } ``` @@ -75,6 +100,32 @@ The HTML and JSON views share a single source of truth: If any repo scan fails during aggregation, the response keeps a valid snapshot for the rest of the data and flags the error path in `health` for transparency. +## Detail groups + +Detail groups are intentionally bounded: + +- users: 12 active hosted users; +- collections: 10 highest record counts; +- latest record: 1 most recently updated current record; +- commit weeks: latest 4 complete or partial Monday-Sunday ranges. + +User cards show handle, DID, active/hosted status, current record count, last +indexed time, and profile imagery when available. Avatar and banner URLs use this +node's existing public blob endpoint and are generated from current +`app.bsky.actor.profile` blob references. The public stats response must not +proxy remote image URLs, expose private blob storage paths, or include full +profile record JSON. + +`latestRecord` identifies the most recently indexed current record across active +hosted users with handle or DID, collection, rkey, CID, and indexed timestamp. +The dashboard does not depend on a third-party record viewer. + +`commitWeeks` groups commit rows by UTC Monday-Sunday week ranges and includes +zero-count weeks inside the returned range so the visual layout stays stable. + +`collections` aggregates records across repos by collection NSID. It sorts by +record count descending, then collection name ascending. + ## Security - Public output must not include: @@ -86,15 +137,32 @@ rest of the data and flags the error path in `health` for transparency. - security event metadata - Public output may include only high-level aggregate counts and aggregate health. -These constraints are enforced in the stats aggregator by reading only sanitized data -from admin status surfaces. +These constraints are enforced in the stats aggregator by reading only sanitized +data from public stats functions. Leak regression tests cover the expanded JSON +shape. + +## Changelog desktop document + +`/changelog` exposes `CHANGELOG.md` as a public document view with a retro word +processor presentation. It uses a fixed `Tempest.Docs` desktop-document manifest, +not arbitrary paths from the request. -## Notes +The changelog renderer: -- `hostedAccountCount`/`totalAccountCount` are computed from account DB state. -- Commit, collection, and record counts are computed by reading repo DBs for hosted - accounts. -- Health checks include writable storage and the minimum set of DB/query checks needed for - confidence in the aggregate response. -- The page is intentionally static and non-interactive; it is not a control panel and - does not expose admin actions. +- renders trusted local Markdown with MDEx; +- parses simple frontmatter if present, but does not require it; +- rejects unknown desktop document slugs and path traversal attempts; +- includes a source view for the raw Markdown body; +- is linked from the home desktop with `priv/static/images/icons/page.svg`; +- uses normal Phoenix/LiveView rendering and no inline scripts. + +## Verification + +```bash +curl -fsS http://localhost:4000/xrpc/_stats +curl -fsS http://localhost:4000/stats +curl -fsS http://localhost:4000/changelog +hurl --test --jobs 1 \ + --variable base_url=http://localhost:4000 \ + test/smoke/public-stats.hurl +``` diff --git a/docs/specs/doc-viewer.md b/docs/specs/doc-viewer.md index 94128e7..8135021 100644 --- a/docs/specs/doc-viewer.md +++ b/docs/specs/doc-viewer.md @@ -4,227 +4,7 @@ updated: 2026-06-19 status: implemented --- -Tempest should expose the project reference documentation as a public, browsable -site. The source of truth remains Markdown under `docs/reference/`; the web UI is -a Phoenix-rendered viewer with a deliberate Web 1.0 / Netscape Navigator-inspired -interface. - -Reference source: - -- [Reference Documentation](../reference/README.md) -- [Architecture](../reference/architecture.md) -- [Deployment and Observability](../reference/deployment-observability.md) -- [PDS Compatibility Matrix](../reference/pds-compatibility.md) - -## Goals - -- Publish `docs/reference/*.md` through the web app without duplicating content. -- Keep Markdown files useful in git, editors, and rendered docs. -- Provide a memorable "Netscape Navigator for a tiny PDS" UI. -- Make the docs easy to scan: navigation tree, document title, updated date, - headings, tables, code blocks, and previous/next links. -- Keep the implementation safe: no arbitrary file reads, no path traversal, no - rendering user-supplied Markdown. -- Avoid heavy client-side behavior. This should work without JavaScript. - -## Non-goals - -- No CMS or browser editing in the first version. -- No public access to admin-only operational data. -- No runtime fetching of remote documentation. -- No real HTML framesets. The UI may visually reference frames, but should use - normal semantic HTML and responsive CSS. -- No inline scripts in templates. - -## Public routes - -Preferred routes: - -```text -GET /docs -GET /docs/:slug -``` - -`/docs` should redirect to or render the reference index from -`docs/reference/README.md`. - -`/:slug` should map only to known reference document slugs. Examples: - -```text -/docs/architecture -/docs/storage-sqlite -/docs/pds-compatibility -``` - -Unknown slugs should return a normal 404 page. - -## Content source and manifest - -Use a fixed manifest rather than accepting arbitrary paths from route params. -The manifest can be a module attribute in a context such as `Tempest.Docs`: - -```elixir -@documents [ - %{slug: "architecture", path: "architecture.md", title: "Architecture"}, - %{slug: "storage-sqlite", path: "storage-sqlite.md", title: "SQLite Storage"}, - %{slug: "xrpc", path: "xrpc.md", title: "XRPC HTTP Surface"} -] -``` - -The manifest should include every intended file from `docs/reference/`. It should -also define display order for sidebar and previous/next navigation. - -The viewer may parse YAML-ish frontmatter for `title` and `updated`, but should -not require frontmatter to render. If frontmatter is missing, derive a title from -the manifest. - -## Markdown rendering - -The first implementation should use a server-side Markdown renderer that supports: - -- headings -- links -- fenced code blocks -- tables -- lists -- inline code -- blockquotes - -Implementation options: - -1. Add a Markdown library such as `MDEx` or `Earmark` and render trusted local - docs on the server. -2. If avoiding a dependency is preferred, render only a conservative subset in a - small local parser. This is lower capability and should not be the default if - tables/code fences become painful. - -Because the input files are trusted project files, rendering can allow normal -Markdown HTML output. Still, the implementation must never render arbitrary -request-provided file contents. If raw HTML in Markdown is enabled, document that -it is trusted-maintainer-only content. - -## Link handling - -Relative links between reference docs should become viewer links where possible: - -```text -./architecture.md -> /docs/architecture -../reference/architecture.md -> /docs/architecture -identity-troubleshooting.md -> /docs/identity-troubleshooting -``` - -External `http://` and `https://` links should remain external and include clear -visual treatment. - -Links to specs, tasks, or files outside `docs/reference/` may either: - -- link to the raw repository path if a public source URL is configured later; or -- remain non-clickable with a `title` explaining that only reference docs are - published in the first version. - -## Visual design: Web 1.0 Netscape Navigator - -The UI should feel like a lovingly restored 1990s documentation browser, not a -modern SaaS docs template. - -Required visual motifs: - -- a faux browser chrome header with a title bar -- toolbar buttons: Back, Forward, Stop, Reload, Home, Search, Print; they may be - decorative or normal links where useful -- a `Location:` bar showing the current `/docs/...` path -- a left "Bookmarks" pane listing reference docs -- a main document pane with beveled borders -- gray system-window surfaces, inset/outset borders, tiled or dithered textures -- blue underlined links and visited-link styling -- compact metadata strip with title, updated date, and document slug -- optional footer details such as "Best viewed in Tempest Navigator" and a static - build/version badge - -The aesthetic should be playful, but the content must stay readable. Use the -project's current vanilla CSS structure responsibly. Add a dedicated component -stylesheet, for example `assets/css/components/doc-viewer.css`, and import it from -`assets/css/app.css`. - -- semantic HTML first -- responsive layout that collapses the bookmarks pane below or above content on - narrow screens -- accessible contrast despite retro colors -- visible keyboard focus states -- no marquee for important content -- no layout implemented with actual HTML tables unless used for document content - -## Suggested layout - -```text -+-----------------------------------------------------------------+ -| Tempest Navigator 4.0 - Reference Documentation | -+-----------------------------------------------------------------+ -| [Back] [Forward] [Stop] [Reload] [Home] [Search] [Print] | -| Location: http://tempest.local/docs/architecture | -+-------------------------+---------------------------------------+ -| Bookmarks | Architecture | -| * Architecture | updated: 2026-06-03 | -| * SQLite Storage | | -| * XRPC | Markdown-rendered reference doc... | -| * Repo Core | | -+-------------------------+---------------------------------------+ -| Best viewed in Tempest Navigator | version ... | /xrpc/_health | -+-----------------------------------------------------------------+ -``` - -## Phoenix implementation notes - -Implemented modules: - -- `Tempest.Docs` context for manifest lookup, file loading, frontmatter parsing, - Markdown rendering, and link rewriting. -- `TempestWeb.DocLive` for the public `/docs` and `/docs/:slug` viewer. - -Route placement: - -```elixir -scope "/", TempestWeb do - pipe_through :browser - - live "/docs", DocLive, :show - live "/docs/:slug", DocLive, :show -end -``` - -The LiveView assigns: - -- `:documents` -- `:document` -- `:html` -- `:previous_document` -- `:next_document` - -When rendering generated HTML in HEEx, only output trusted converted docs. Use a -clear boundary function so reviewers can see where HTML safety is decided. - -## Caching - -Docs are static project files. The current policy is: - -- `dev`: read every request for fast documentation iteration -- `test`: read every request -- `prod`: cache successfully rendered manifest documents in `:persistent_term` - -Do not cache route params that are not in the manifest. - -## Tests - -Required test coverage: - -- `/docs` renders without authentication. -- `/docs/architecture` renders the architecture reference doc. -- unknown slug returns 404. -- path traversal attempts fail, e.g. `/docs/..%2F..%2Fconfig%2Fprod.exs`. -- sidebar contains known reference docs. -- rendered document includes headings, code blocks, and tables from sample docs. -- private/admin docs or paths outside `docs/reference/` cannot be read. -- relative links to known reference docs are rewritten to `/docs/:slug`. +Reference documentation: ../reference/doc-viewer.md ## HTTP verification diff --git a/docs/specs/public-stats-dashboard.md b/docs/specs/public-stats-dashboard.md index 3fb5b0f..876c93a 100644 --- a/docs/specs/public-stats-dashboard.md +++ b/docs/specs/public-stats-dashboard.md @@ -1,151 +1,11 @@ --- title: Public Stats Dashboard updated: 2026-06-14 -status: implemented; planned expansion +status: implemented --- Reference documentation: ../reference/public-stats-dashboard.md -## Planned expansion - -The next dashboard iteration should move beyond aggregate counters and expose a -small public snapshot of activity on the node: - -- user cards -- latest indexed record; -- weekly commit activity, grouped Monday through Sunday; -- collection summaries with per-collection record counts. - -The data should come from the same sanitized public stats boundary used by -`/xrpc/_stats` and `/stats`. Do not query private admin status data directly from -the view layer. - -## Public data contract - -Extend the public stats snapshot with optional detail groups: - -```json -{ - "users": [ - { - "did": "did:plc:example", - "handle": "alice.example.com", - "status": "active", - "recordCount": 42, - "lastIndexedAt": "2026-06-14T18:57:11Z", - "avatarUrl": "/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aexample&cid=baf...", - "bannerUrl": "/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aexample&cid=baf..." - } - ], - "latestRecord": { - "did": "did:plc:example", - "handle": "alice.example.com", - "collection": "app.bsky.feed.post", - "rkey": "3k...", - "cid": "baf...", - "indexedAt": "2026-06-14T18:57:11Z" - }, - "commitWeeks": [{ "weekStart": "2026-06-08", "weekEnd": "2026-06-14", "commitCount": 31 }], - "collections": [{ "collection": "app.bsky.feed.post", "recordCount": 120 }] -} -``` - -All detail groups should be bounded - -- users: 12 active hosted users; -- collections: 10 highest record counts; -- latest record: 1 most recently updated current record; -- commit weeks: latest 4 complete or partial Monday-Sunday ranges. - -## User cards - -Each user card should show: - -- banner image when the user's `app.bsky.actor.profile` record contains a banner - blob reference; -- avatar image when the profile record contains an avatar blob reference; -- deterministic fallback treatment when either image is missing; -- handle; -- DID in a compact monospace line; -- active/hosted status; -- current record count. - -Avatar and banner URLs should use this node's existing public blob endpoint and -must be generated from current profile record blob references. Do not proxy -remote image URLs, do not expose private blob storage paths, and do not include -the full profile record JSON in public stats. - -If profile record parsing fails for one user, keep rendering the rest of the -snapshot and count the failure as a public stats scan error. - -## Latest indexed record - -Show the most recently indexed current record across active hosted users. The -display should include: - -- handle or DID; -- collection; -- rkey; -- CID; -- indexed timestamp. - -The dashboard may link to a public AT Protocol record viewer later, but the first -version should not depend on a third-party viewer to render correctly. - -## Weekly commit activity - -Replace the vague "commit graph" idea with a weekly histogram: - -- week ranges are Monday through Sunday; -- `weekStart` and `weekEnd` are ISO dates; -- count commits by each repo commit row's `inserted_at`; -- include weeks with zero commits inside the returned range so the visual does - not jump; -- use UTC for grouping unless a future config explicitly chooses another zone. - -The UI should render this as a compact bar chart or segmented strip. It should -remain useful with no JavaScript. - -## Collection summaries - -Collections should show collection NSID and record count. Sort by record count -descending, then collection name ascending for stable output. If multiple repos -contain the same collection, aggregate them into one row. - -## Changelog desktop document - -Tempest should also expose `CHANGELOG.md` as a public document view that feels -like a word processor document rather than the existing browser-style docs -viewer. - -This should reuse prior markdown rendering infrastructure from `Tempest.Docs` -where practical: - -- keep lookup manifest-based, not arbitrary path-based; -- render trusted local Markdown with MDEx; -- parse simple frontmatter only if present, but do not require it; -- avoid rendering user-supplied Markdown; -- preserve the ability to copy or read the raw Markdown if the existing docs - viewer pattern makes that cheap. - -Suggested route: - -```text -GET /changelog -``` - -The home desktop should link to it with `priv/static/images/icons/page.svg` and a -short label such as "Changelog". - -The visual design should read as a retro word processor / document window: - -- desktop shell consistent with the home and docs pages; -- page icon on the desktop; -- title bar and document toolbar; -- white document canvas with readable typography; -- print-like page width; -- no inline scripts. - Verification: ```bash diff --git a/docs/tasks/14-local-pds-compatibility.md b/docs/tasks/14-local-pds-compatibility.md index 688df90..ecc2845 100644 --- a/docs/tasks/14-local-pds-compatibility.md +++ b/docs/tasks/14-local-pds-compatibility.md @@ -7,48 +7,21 @@ specs: - ../specs/migration-lifecycle.md --- -Goal: prove Tempest's PDS behavior locally before relying on deployment, -public DNS, TLS, relays, or AppViews. +Completed [June 12, 2026](../../CHANGELOG.md#2026-06-12). -Use `ConnCase` for detailed endpoint checks. Keep Hurl for running-server smoke -coverage through the same public HTTP/WebSocket contract clients use. - -- [x] T14-01: Keep the endpoint compatibility matrix aligned with implemented - behavior and reference Lexicons. -- [x] T14-02: Add ConnCase response-shape and error-shape tests for core PDS - endpoints. -- [x] T14-03: Add ConnCase auth tests for bearer tokens, app passwords, OAuth - tokens, admin tokens, and missing credentials. -- [x] T14-04: Add ConnCase content-type and verb tests for XRPC endpoints. -- [x] T14-05: Add HTTP black-box tests for login, write, read, blob, CAR, and - firehose flows against a local server. -- [x] T14-06: Add OAuth and app-password black-box compatibility tests. -- [x] T14-07: Add migration-in and migration-out compatibility tests using two - local Tempest instances. -- [x] T14-08: Add an explicit AppView proxy/fallback policy and local coverage - for unknown `app.bsky.*` methods. -- [x] T14-09: Add firehose frame comparison tests for header/body CBOR shape, - backfill, live events, and deactivated accounts. -- [x] T14-10: Add local restore-drill test that verifies DBs, repos, blobs, - signing keys, and OAuth keys together. -- [x] T14-11: Add local Hurl smoke profile that runs the completed compatibility - suites without deployment. - -## Integration Tests - -- ConnCase covers request parsing, auth, response shape, and error shape for the - endpoint matrix. -- HTTP black-box tests can create an account, authenticate, write records, upload blobs, - read records, export CAR, and observe firehose events. -- OAuth and app-password flows work through public HTTP endpoints, not internal - context calls. -- Migration tests prove import, activation, deactivation, service auth, missing - blob listing, and migration-out behavior. -- AppView fallback behavior is documented and tested with mocked outbound HTTP. -- Restore drill produces a server state that passes read-only smoke tests. - -## HTTP Verification +## Verification ```bash test/smoke/local-pds-compat.sh http://localhost:4000 ``` + +The local profile proves endpoint shape, auth boundaries, content types, black-box +account/repo/blob/CAR/firehose flows, migration behavior, AppView fallback policy, +and restore-drill compatibility before deployment. + +Reference documentation: + +- [PDS Compatibility Matrix](../reference/pds-compatibility.md) +- [Interop and Integration Testing](../reference/interop-testing.md) +- [Security, OAuth, and Delegated Access](../reference/security-oauth.md) +- [Migration and Account Lifecycle](../reference/migration-lifecycle.md) diff --git a/docs/tasks/15-deployment-verification.md b/docs/tasks/15-deployment-verification.md index d3acc58..d77f740 100644 --- a/docs/tasks/15-deployment-verification.md +++ b/docs/tasks/15-deployment-verification.md @@ -9,45 +9,9 @@ references: - ../reference/budget.md --- -Goal: make Tempest deployable, restorable, and externally verifiable as a -SQLite-first PDS on local Docker or a managed PaaS with optional S3/R2 storage. +Completed [June 13, 2026](../../CHANGELOG.md#2026-06-13). -- [x] T15-01: Add release configuration. -- [x] T15-02: Add Dockerfile. -- [x] T15-03: Add docker-compose example. -- [x] T15-04: Add Caddy reverse proxy example. -- [x] T15-05: Add production env template. -- [x] T15-06: Add deployment docs for local-only, S3-backed, and reverse-proxy - setups. -- [x] T15-07: Add managed PaaS deployment profile for Railway-like hosts. -- [x] T15-08: Document persistent volume requirements for SQLite, repos, keys, - WAL files, and backup workspaces. -- [x] T15-09: Add Cloudflare R2 blob-store configuration docs. -- [x] T15-10: Add Cloudflare R2 backup-store configuration docs. -- [x] T15-11: Add deployed HTTPS/WebSocket smoke test profile. -- [x] T15-12: Add Hurl smoke test for deployed HTTPS target. -- [x] T15-13: Add restore drill for managed PaaS volume plus S3/R2 backups. -- [x] T15-14: Add public DID and handle verification procedure. -- [x] T15-15: Add public relay/AppView crawl verification procedure for a - deployed HTTPS node. -- [x] T15-16: Add real-client compatibility checklist for deployed login, - profile writes, posts, blobs, and session refresh. -- [x] T15-17: Add budget deployment guide for Railway Hobby plus Cloudflare R2 - free-tier planning. - -## Integration Tests - -- Release boots with a mounted data dir. -- Managed PaaS profile documents which paths must be durable. -- Production boot refuses default secrets. -- Backup docs are exercised in a local temporary directory. -- Restore drill can rebuild from a fresh volume and S3/R2 backup. -- Deployed HTTPS smoke test verifies health, XRPC, blob reads, and WebSocket - behavior. -- Public verification proves hosted DID, handle resolution, and relay/AppView - crawl behavior for the deployed node. - -## HTTP Verification +## Verification ```bash hurl --test --jobs 1 \ @@ -55,3 +19,15 @@ hurl --test --jobs 1 \ --variable admin_token="$ADMIN_TOKEN" \ test/smoke/deployment.hurl ``` + +Deployment verification covers release/container boot, durable volume +requirements, production secret checks, S3/R2-backed backup and blob profiles, +restore drills, public DID/handle verification, relay/AppView crawl checks, and +real-client smoke flows. + +Reference documentation: + +- [Deployment Guide](../reference/deployment.md) +- [Deployment and Observability](../reference/deployment-observability.md) +- [Budget Deployment](../reference/budget.md) +- [PDS Compatibility Matrix](../reference/pds-compatibility.md) diff --git a/docs/tasks/16-public-stats-dashboard.md b/docs/tasks/16-public-stats-dashboard.md index 4e1d342..7326333 100644 --- a/docs/tasks/16-public-stats-dashboard.md +++ b/docs/tasks/16-public-stats-dashboard.md @@ -9,73 +9,9 @@ references: - ../reference/admin-operations.md --- -Goal: expose safe public aggregate stats for the experimental Tempest PDS while -keeping admin-only operations and sensitive internals private. +Completed [June 14, 2026](../../CHANGELOG.md#2026-06-14). -- [x] T16-01: Add a public stats context or sanitized stats function. - It should not reuse the full private admin status response directly. -- [x] T16-02: Extend repo stats to include per-repo `commit_count`, - `collection_count`, and latest repo activity timestamps. -- [x] T16-03: Add aggregate counts for hosted accounts, total accounts, commits, - collections, records, and `lastIndexedAt`. -- [x] T16-04: Add application uptime tracking based on monotonic time recorded at - application start. -- [x] T16-05: Add a public health summary with `ok`, `degraded`, and `unhealthy` - states. -- [x] T16-06: Add `GET /xrpc/_stats` returning sanitized public JSON. -- [x] T16-07: Add `GET /stats` public HTML dashboard. -- [x] T16-08: Link the public stats dashboard from the home page. -- [x] T16-09: Add dashboard cards for hosted accounts, commits, collections, - records, last indexed, uptime, and health. -- [x] T16-10: Add helper copy explaining that `lastIndexedAt` is local repo, - commit, or sequencer activity observed by this PDS. -- [x] T16-11: Add ConnCase tests for `/stats` and `/xrpc/_stats` without admin - authorization. -- [x] T16-12: Add regression tests proving public stats do not include email, - token, session, OAuth, backup path, admin token, or private filesystem data. -- [x] T16-13: Add Hurl smoke test `test/smoke/public-stats.hurl`. -- [x] T16-14: Document cache behavior if stats are cached. Include `generatedAt` - in the JSON response either way. -- [x] T16-15: Rename the public account detail concept to "users". -- [x] T16-16: Add the public `users` group described in the stats spec. -- [x] T16-17: Add public avatar and banner support for user cards. -- [x] T16-18: Render user cards on `/stats`. -- [x] T16-19: Add the public `latestRecord` group described in the stats spec. -- [x] T16-20: Render a "Latest Indexed Record" section on `/stats`. -- [x] T16-21: Add repo storage support for weekly commit counts. -- [x] T16-22: Add the public `commitWeeks` group described in the stats spec. -- [x] T16-23: Render a compact weekly commit histogram on `/stats`. -- [x] T16-24: Add repo storage support for collection summaries. -- [x] T16-25: Add the public `collections` group described in the stats spec. -- [x] T16-26: Render collection summary rows on `/stats` with count bars. -- [x] T16-27: Extend public stats tests for `users`, avatar/banner URLs, - `latestRecord`, `commitWeeks`, and collection summaries. -- [x] T16-28: Extend leak regression tests for the expanded public stats shape. -- [x] T16-29: Extend `test/smoke/public-stats.hurl` to cover the new JSON fields - and `/stats` sections. -- [x] T16-30: Add the public changelog document route described in the stats - spec. -- [x] T16-31: Keep changelog source lookup constrained to a fixed manifest entry. -- [x] T16-32: Style `/changelog` as a word processor document window. -- [x] T16-33: Link `/changelog` from the desktop shortcuts. -- [x] T16-34: Add ConnCase coverage for `/changelog`, its desktop shortcut, and - rejection of arbitrary file/path rendering. -- [x] T16-35: Add a changelog smoke check to the public stats or docs smoke - suite, depending on where the route is implemented. - -## Integration Tests - -- Public stats JSON works without an admin token. -- Public stats HTML works without an admin token. -- Admin-only status remains protected by the existing admin auth checks. -- Counts reflect created accounts and repo writes in an isolated test database. -- Health reports degraded or unhealthy when a required check is forced to fail. -- Public responses omit sensitive fields. -- Expanded public stats match the data contract in - `docs/specs/public-stats-dashboard.md`. -- `/changelog` renders `CHANGELOG.md` publicly and is linked from the desktop. - -## HTTP Verification +## Verification ```bash curl -fsS http://localhost:4000/xrpc/_stats @@ -86,15 +22,8 @@ hurl --test --jobs 1 \ test/smoke/public-stats.hurl ``` -## Implementation Notes - -Prefer simple request-time aggregation first. If it becomes slow, add a short TTL -cache and keep the response honest with `generatedAt`. - -Current behavior: public stats are generated on each request and are not cached. -Every JSON response includes `generatedAt`, the UTC timestamp for that response's -request-time snapshot. If a short TTL cache is added later, `generatedAt` must -remain the timestamp for the cached snapshot, not the time a client receives it. +Public stats expose sanitized aggregate and bounded activity data without auth, +keep admin-only status protected, omit sensitive fields, and render +`CHANGELOG.md` through a constrained desktop document route. -Detailed data contracts, UI behavior, and privacy rules live in -`docs/specs/public-stats-dashboard.md`. +Reference documentation: [Public Stats Dashboard](../reference/public-stats-dashboard.md). diff --git a/docs/tasks/17-doc-viewer.md b/docs/tasks/17-doc-viewer.md index ad5fdfc..b3f6f0b 100644 --- a/docs/tasks/17-doc-viewer.md +++ b/docs/tasks/17-doc-viewer.md @@ -8,49 +8,9 @@ references: - ../reference/architecture.md --- -Goal: publish `docs/reference/` as a public Phoenix documentation site with a -Netscape Navigator-inspired shell and a web 1.0 design (with a sidebar & search) +Completed [June 19, 2026](../../CHANGELOG.md#2026-06-14). -- [x] T17-01: Add a `Tempest.Docs` context with a fixed manifest for files under - `docs/reference/`. -- [x] T17-02: Add safe document lookup by slug. Reject unknown slugs and any path - traversal attempt. -- [x] T17-03: Add frontmatter parsing for `title` and `updated`, falling back to - manifest values when frontmatter is missing. -- [x] T17-04: Add server-side Markdown rendering for trusted local reference docs - with `MDEx` -- [x] T17-05: Add relative-link rewriting for links between known reference docs. -- [x] T17-06: Add `TempestWeb.DocController` with `index` and `show` actions. -- [x] T17-07: Add public routes `GET /docs` and `GET /docs/:slug` under the - browser pipeline. -- [x] T17-08: Add `TempestWeb.DocHTML` templates for the doc viewer. -- [x] T17-09: Build the Netscape-style chrome: title bar, toolbar buttons, - location bar, bookmarks pane, document pane, and footer. -- [x] T17-10: Add responsive CSS through the existing vanilla CSS structure - (`assets/css/app.css` plus component files such as - `assets/css/components/doc-viewer.css`). -- [x] T17-11: Add accessible focus, contrast, heading, and navigation behavior. -- [x] T17-12: Add previous/next document links based on manifest order. -- [x] T17-13: Link the docs viewer from the home page and any relevant public - navigation. -- [x] T17-14: Add ConnCase tests for `/docs`, `/docs/architecture`, unknown slugs, - sidebar navigation, relative-link rewriting, and path traversal rejection. -- [x] T17-15: Add regression tests proving files outside `docs/reference/` cannot - be rendered. -- [x] T17-16: Add Hurl smoke test `test/smoke/doc-viewer.hurl`. -- [x] T17-17: Add production caching - -## Integration Tests - -- Public docs routes work without authentication. -- The architecture reference document renders through the viewer. -- Sidebar/bookmarks include the manifest documents. -- Unknown slugs return 404. -- Path traversal does not read local files. -- Relative links between reference docs resolve to `/docs/:slug`. -- The page remains usable without JavaScript. - -## HTTP Verification +## Verification ```bash curl -fsS http://localhost:4000/docs @@ -60,20 +20,9 @@ hurl --test --jobs 1 \ test/smoke/doc-viewer.hurl ``` -## Design Notes - -The design should look like a real retro browser, not a generic docs template. -Required motifs: - -- faux Netscape-style title bar -- beveled gray toolbar -- Back / Forward / Stop / Reload / Home / Search / Print controls -- `Location:` input-style path display -- left bookmarks pane -- main document pane -- blue underlined links -- dithered or tiled-feeling background texture -- "Best viewed in Tempest Navigator" footer copy +The viewer publishes the fixed `docs/reference/` manifest through `/docs` and +`/docs/:slug`, renders trusted local Markdown, rewrites known reference links, +rejects unknown/path-traversal slugs, and keeps the retro browser shell usable +without JavaScript. -Keep it semantic and responsive. Do not use actual framesets. Do not use inline -scripts. Do not reference external vendored assets from layouts. +Reference documentation: [Documentation Viewer](../reference/doc-viewer.md). diff --git a/docs/tasks/18-account-management.md b/docs/tasks/18-account-management.md index 4864576..4e76c70 100644 --- a/docs/tasks/18-account-management.md +++ b/docs/tasks/18-account-management.md @@ -18,33 +18,33 @@ External personal backups become an admin-only account-management feature. ## Auth And Routing -- [ ] T18-01: Add a browser-friendly account login page at `/account/login` +- [x] T18-01: Add a browser-friendly account login page at `/account/login` backed by the existing account credential/session flow. -- [ ] T18-02: Add account logout and an account browser-session plug that +- [x] T18-02: Add account logout and an account browser-session plug that authorizes `/account/*` from a session family or server-side session reference without requiring manual bearer headers. -- [ ] T18-03: Preserve bearer-token access for existing account tool smoke tests +- [x] T18-03: Preserve bearer-token access for existing account tool smoke tests while ensuring browser sessions never store or render access or refresh tokens. -- [ ] T18-04: Add `TEMPEST_ADMIN_DID` config and validation. Admin browser auth +- [x] T18-04: Add `TEMPEST_ADMIN_DID` config and validation. Admin browser auth must be anchored to this DID rather than a hardcoded PDS URL. -- [ ] T18-05: Add an admin login page at `/admin/login` that resolves +- [x] T18-05: Add an admin login page at `/admin/login` that resolves `TEMPEST_ADMIN_DID`, discovers the current auth method, and authenticates either through local account login or AT Protocol OAuth. -- [ ] T18-06: Store only a server-side admin auth reference in the browser +- [x] T18-06: Store only a server-side admin auth reference in the browser session. Do not store raw admin tokens, OAuth access tokens, refresh tokens, DPoP keys, or authorization artifacts in the browser session. -- [ ] T18-07: Keep `TEMPEST_ADMIN_TOKEN_HASH` available only as a bootstrap or +- [x] T18-07: Keep `TEMPEST_ADMIN_TOKEN_HASH` available only as a bootstrap or automation credential where still needed by JSON/status checks. -- [ ] T18-08: Add admin logout and an admin browser-session plug that accepts a +- [x] T18-08: Add admin logout and an admin browser-session plug that accepts a valid admin session and, for automation-only paths, the configured admin bearer token. -- [ ] T18-09: Put user and admin LiveViews in separate authenticated +- [x] T18-09: Put user and admin LiveViews in separate authenticated `live_session` groups. -- [ ] T18-10: Replace the current controller-backed account/admin tooling in +- [x] T18-10: Replace the current controller-backed account/admin tooling in place. Do not preserve old `/account/*` or `/admin/*` controller routes as redirects. -- [ ] T18-11: Add tests proving account sessions cannot access `/admin/*` and +- [x] T18-11: Add tests proving account sessions cannot access `/admin/*` and admin sessions cannot act as account auth for account-only XRPC methods. ## User Account Management diff --git a/lib/tempest/accounts.ex b/lib/tempest/accounts.ex index 106898d..09129c2 100644 --- a/lib/tempest/accounts.ex +++ b/lib/tempest/accounts.ex @@ -78,6 +78,15 @@ defmodule Tempest.Accounts do def create_session(_identifier, _password), do: {:error, :invalid_credentials} + def create_browser_session(identifier, password) when is_binary(identifier) and is_binary(password) do + with {:ok, response} <- create_session(identifier, password), + {:ok, auth} <- authenticate_refresh(response["refreshJwt"]) do + {:ok, %{account: auth.account, session: auth.session, family_id: auth.session.family_id}} + end + end + + def create_browser_session(_identifier, _password), do: {:error, :invalid_credentials} + defp create_session_after_rate_limit(identifier, password) do account = Account @@ -317,6 +326,53 @@ defmodule Tempest.Accounts do def authenticate_refresh(_token), do: {:error, :invalid_token} + def authenticate_browser_session(session_id, family_id) when is_integer(session_id) and is_binary(family_id) do + session = + Session + |> where([s], s.id == ^session_id and s.family_id == ^family_id) + |> preload(:account) + |> Repo.one() + + now = now() + + cond do + is_nil(session) -> + {:error, :invalid_token} + + session.revoked_at -> + {:error, :invalid_token} + + DateTime.compare(session.expires_at, now) != :gt -> + revoke_session_family!(session.family_id, now) + {:error, :expired_token} + + not refresh_allowed_for_account?(session.account) -> + {:error, :inactive_account} + + true -> + {:ok, %AuthContext{account: session.account, session: session, token_type: :browser_session}} + end + end + + def authenticate_browser_session(session_id, family_id) when is_binary(session_id) and is_binary(family_id) do + case Integer.parse(session_id) do + {id, ""} -> authenticate_browser_session(id, family_id) + _other -> {:error, :invalid_token} + end + end + + def authenticate_browser_session(_session_id, _family_id), do: {:error, :invalid_token} + + def revoke_browser_session(session_id, family_id) do + with {:ok, auth} <- authenticate_browser_session(session_id, family_id) do + now = now() + revoke_session_family!(auth.session.family_id, now) + :ok + else + {:error, _reason} -> :ok + end + end + defp refresh_allowed_for_account?(%Account{active: true, status: "active"}), do: true defp refresh_allowed_for_account?(%Account{active: false, status: "deactivated"}), do: true defp refresh_allowed_for_account?(%Account{}), do: false diff --git a/lib/tempest/admin.ex b/lib/tempest/admin.ex index 73238d5..9824140 100644 --- a/lib/tempest/admin.ex +++ b/lib/tempest/admin.ex @@ -45,7 +45,7 @@ defmodule Tempest.Admin do %{ "status" => "ok", "version" => Tempest.version(), - "admin" => %{"tokenConfigured" => Tempest.AdminAuth.configured?()}, + "admin" => admin_status(), "storage" => Storage.health(config, env), "database" => database_status(config), "sequencer" => sequencer_status(), @@ -54,6 +54,16 @@ defmodule Tempest.Admin do } end + defp admin_status do + {did, method} = + case Tempest.AdminAuth.auth_method() do + {:ok, %{did: did, method: method}} -> {did, Atom.to_string(method)} + {:error, reason} -> {nil, Atom.to_string(reason)} + end + + %{"did" => did, "authMethod" => method, "tokenConfigured" => Tempest.AdminAuth.configured?()} + end + def compatibility_status do endpoints = Enum.map(@compatibility_methods, &compatibility_endpoint/1) diff --git a/lib/tempest/admin_auth.ex b/lib/tempest/admin_auth.ex index 75e3e35..347be49 100644 --- a/lib/tempest/admin_auth.ex +++ b/lib/tempest/admin_auth.ex @@ -6,7 +6,10 @@ defmodule Tempest.AdminAuth do Argon2 hash in `:tempest, :admin_token_hash` or `TEMPEST_ADMIN_TOKEN_HASH`. """ - alias Tempest.Accounts.Password + import Ecto.Query + + alias Tempest.Accounts.{Account, Password} + alias Tempest.{Accounts, Config, Identity, Repo} @doc """ Hashes a plaintext admin token for configuration. @@ -48,11 +51,72 @@ defmodule Tempest.AdminAuth do end end + def configured_did do + case Config.load!().admin_did || System.get_env("TEMPEST_ADMIN_DID") do + did when is_binary(did) and did != "" -> {:ok, did} + _missing -> {:error, :admin_did_not_configured} + end + end + + def auth_method do + with {:ok, did} <- configured_did() do + cond do + local_admin_account?(did) -> + {:ok, %{did: did, method: :local_account}} + + match?({:ok, _document}, Identity.did_document_for_did(did)) -> + {:ok, %{did: did, method: :oauth}} + + true -> + {:error, :admin_did_not_found} + end + end + end + + def create_local_browser_session(identifier, password) do + with {:ok, admin_did} <- configured_did(), + {:ok, browser_session} <- Accounts.create_browser_session(identifier, password), + ^admin_did <- browser_session.account.did do + {:ok, %{did: admin_did, session: browser_session.session, family_id: browser_session.family_id}} + else + {:error, reason} -> {:error, reason} + _other -> {:error, :not_admin_account} + end + end + + def authenticate_browser_session(session_id, family_id, did) when is_binary(family_id) and is_binary(did) do + with {:ok, admin_did} <- configured_did(), + ^admin_did <- did, + {:ok, auth} <- Accounts.authenticate_browser_session(session_id, family_id), + ^admin_did <- auth.account.did do + {:ok, %{did: admin_did, account: auth.account, session: auth.session, token_type: :admin_browser_session}} + else + {:error, reason} -> {:error, reason} + _other -> {:error, :invalid_admin_session} + end + end + + def authenticate_browser_session(_session_id, _family_id, _did), do: {:error, :invalid_admin_session} + + def revoke_browser_session(session_id, family_id, did) do + with {:ok, _auth} <- authenticate_browser_session(session_id, family_id, did) do + Accounts.revoke_browser_session(session_id, family_id) + else + {:error, _reason} -> :ok + end + end + @doc """ Returns true when an admin token hash is configured. """ def configured?, do: is_binary(configured_hash()) and configured_hash() != "" + defp local_admin_account?(did) do + Account + |> where([account], account.did == ^did and account.active and account.status == "active") + |> Repo.exists?() + end + defp configured_hash do Application.get_env(:tempest, :admin_token_hash) || System.get_env("TEMPEST_ADMIN_TOKEN_HASH") end diff --git a/lib/tempest/config.ex b/lib/tempest/config.ex index bffaeb1..9219182 100644 --- a/lib/tempest/config.ex +++ b/lib/tempest/config.ex @@ -4,14 +4,15 @@ defmodule Tempest.Config do """ @enforce_keys [:hostname, :public_url, :data_dir, :blob_max_bytes, :hosted_did_method] - defstruct [:hostname, :public_url, :data_dir, :blob_max_bytes, :hosted_did_method] + defstruct [:hostname, :public_url, :data_dir, :blob_max_bytes, :hosted_did_method, :admin_did] @type t :: %__MODULE__{ hostname: String.t(), public_url: String.t(), data_dir: String.t(), blob_max_bytes: pos_integer(), - hosted_did_method: :plc | :web + hosted_did_method: :plc | :web, + admin_did: String.t() | nil } @default_secret_key_bases [ @@ -54,7 +55,8 @@ defmodule Tempest.Config do public_url: Keyword.get(config, :public_url), data_dir: Keyword.get(config, :data_dir), blob_max_bytes: Keyword.get(config, :blob_max_bytes), - hosted_did_method: Keyword.get(config, :hosted_did_method, :plc) + hosted_did_method: Keyword.get(config, :hosted_did_method, :plc), + admin_did: normalize_optional_string(Keyword.get(config, :admin_did)) } env = Keyword.get(opts, :env, Application.get_env(:tempest, :env, :prod)) @@ -65,6 +67,7 @@ defmodule Tempest.Config do :ok <- validate_data_dir(config.data_dir), :ok <- validate_blob_max_bytes(config.blob_max_bytes), :ok <- validate_hosted_did_method(config.hosted_did_method), + :ok <- validate_admin_did(config.admin_did), :ok <- validate_prod_secret(env, endpoint_config) do config else @@ -148,6 +151,15 @@ defmodule Tempest.Config do defp validate_hosted_did_method("web"), do: :ok defp validate_hosted_did_method(_method), do: {:error, "hosted_did_method must be plc or web"} + defp validate_admin_did(nil), do: :ok + + defp validate_admin_did(admin_did) do + case Tempest.Identity.Validators.validate_did(admin_did) do + :ok -> :ok + {:error, _reason} -> {:error, "admin_did must be a supported DID"} + end + end + defp validate_prod_secret(:prod, endpoint_config) do secret_key_base = Keyword.get(endpoint_config, :secret_key_base) @@ -188,4 +200,13 @@ defmodule Tempest.Config do |> String.replace(~r/[^A-Za-z0-9._-]/, "_") |> Kernel.<>(".sqlite") end + + defp normalize_optional_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_optional_string(_value), do: nil end diff --git a/lib/tempest/docs.ex b/lib/tempest/docs.ex index e19b77a..c3a8656 100644 --- a/lib/tempest/docs.ex +++ b/lib/tempest/docs.ex @@ -28,6 +28,7 @@ defmodule Tempest.Docs do %{slug: "car-drisl", path: "car-drisl.md", title: "CAR and DRISL"}, %{slug: "deployment", path: "deployment.md", title: "Deployment Guide"}, %{slug: "deployment-observability", path: "deployment-observability.md", title: "Deployment and Observability"}, + %{slug: "doc-viewer", path: "doc-viewer.md", title: "Documentation Viewer"}, %{slug: "endpoints", path: "endpoints.md", title: "Endpoints"}, %{slug: "identity-handles", path: "identity-handles.md", title: "Identity and Handles"}, %{slug: "identity-troubleshooting", path: "identity-troubleshooting.md", title: "Identity Troubleshooting"}, diff --git a/lib/tempest_web/controllers/account_session_controller.ex b/lib/tempest_web/controllers/account_session_controller.ex new file mode 100644 index 0000000..0658f58 --- /dev/null +++ b/lib/tempest_web/controllers/account_session_controller.ex @@ -0,0 +1,75 @@ +defmodule TempestWeb.AccountSessionController do + use TempestWeb, :controller + + alias Tempest.Accounts + + def new(conn, params) do + render_login(conn, params, nil) + end + + def create(conn, %{"account" => account_params} = params) do + identifier = Map.get(account_params, "identifier", "") + password = Map.get(account_params, "password", "") + return_to = return_to(params) + + case Accounts.create_browser_session(identifier, password) do + {:ok, browser_session} -> + conn + |> renew_session() + |> put_session(:account_session_id, browser_session.session.id) + |> put_session(:account_session_family_id, browser_session.family_id) + |> put_session(:account_did, browser_session.account.did) + |> redirect(to: safe_return_to(return_to, ~p"/account")) + + {:error, reason} -> + conn + |> put_status(:unauthorized) + |> render_login(params, login_error(reason)) + end + end + + def create(conn, params), do: create(conn, Map.put(params, "account", %{})) + + def delete(conn, params) do + Accounts.revoke_browser_session( + get_session(conn, :account_session_id), + get_session(conn, :account_session_family_id) + ) + + conn + |> renew_session() + |> redirect(to: safe_return_to(return_to(params), ~p"/")) + end + + defp render_login(conn, params, error) do + render(conn, :new, + form: Phoenix.Component.to_form(%{}, as: :account), + return_to: safe_return_to(return_to(params), ~p"/"), + error: error + ) + end + + defp return_to(params), do: Map.get(params, "return_to") + + defp safe_return_to(path, fallback) when is_binary(path) do + cond do + path == "" -> fallback + String.starts_with?(path, "//") -> fallback + String.contains?(path, ["\r", "\n"]) -> fallback + String.starts_with?(path, "/") -> path + true -> fallback + end + end + + defp safe_return_to(_path, fallback), do: fallback + + defp login_error(:inactive_account), do: "This account is not active." + defp login_error(:rate_limited), do: "Too many attempts. Try again later." + defp login_error(_reason), do: "The user name or password is incorrect." + + defp renew_session(conn) do + conn + |> configure_session(renew: true) + |> clear_session() + end +end diff --git a/lib/tempest_web/controllers/account_session_html.ex b/lib/tempest_web/controllers/account_session_html.ex new file mode 100644 index 0000000..41db340 --- /dev/null +++ b/lib/tempest_web/controllers/account_session_html.ex @@ -0,0 +1,5 @@ +defmodule TempestWeb.AccountSessionHTML do + use TempestWeb, :html + + embed_templates "account_session_html/*" +end diff --git a/lib/tempest_web/controllers/account_session_html/new.html.heex b/lib/tempest_web/controllers/account_session_html/new.html.heex new file mode 100644 index 0000000..516257d --- /dev/null +++ b/lib/tempest_web/controllers/account_session_html/new.html.heex @@ -0,0 +1,53 @@ +
+ +
diff --git a/lib/tempest_web/controllers/admin_controller.ex b/lib/tempest_web/controllers/admin_controller.ex index 51e985e..538ffb4 100644 --- a/lib/tempest_web/controllers/admin_controller.ex +++ b/lib/tempest_web/controllers/admin_controller.ex @@ -41,18 +41,24 @@ defmodule TempestWeb.AdminController do end defp with_admin(conn, fun) do - case AdminAuth.verify_authorization_header(conn.req_headers) do - :ok -> + cond do + conn.assigns[:admin_auth] -> fun.(conn) - {:error, :missing_admin_token} -> - reject(conn, 401, "AuthenticationRequired", "Admin bearer token is required") + true -> + case AdminAuth.verify_authorization_header(conn.req_headers) do + :ok -> + fun.(conn) + + {:error, :missing_admin_token} -> + reject(conn, 401, "AuthenticationRequired", "Admin bearer token is required") - {:error, :admin_token_not_configured} -> - reject(conn, 503, "AdminAuthNotConfigured", "Admin token hash is not configured") + {:error, :admin_token_not_configured} -> + reject(conn, 503, "AdminAuthNotConfigured", "Admin token hash is not configured") - {:error, _reason} -> - reject(conn, 401, "InvalidToken", "Admin bearer token is invalid") + {:error, _reason} -> + reject(conn, 401, "InvalidToken", "Admin bearer token is invalid") + end end end diff --git a/lib/tempest_web/controllers/admin_session_controller.ex b/lib/tempest_web/controllers/admin_session_controller.ex new file mode 100644 index 0000000..fc0daae --- /dev/null +++ b/lib/tempest_web/controllers/admin_session_controller.ex @@ -0,0 +1,111 @@ +defmodule TempestWeb.AdminSessionController do + use TempestWeb, :controller + + alias Tempest.AdminAuth + + def new(conn, params) do + render_login(conn, params, nil) + end + + def create(conn, %{"admin" => admin_params} = params) do + case AdminAuth.auth_method() do + {:ok, %{method: :local_account}} -> + create_local_session(conn, admin_params, params) + + {:ok, %{method: :oauth}} -> + conn + |> put_status(:not_implemented) + |> render_login(params, "This admin DID is hosted externally and requires AT Protocol OAuth.") + + {:error, reason} -> + conn + |> put_status(:service_unavailable) + |> render_login(params, login_error(reason)) + end + end + + def create(conn, params), do: create(conn, Map.put(params, "admin", %{})) + + def delete(conn, params) do + AdminAuth.revoke_browser_session( + get_session(conn, :admin_session_id), + get_session(conn, :admin_session_family_id), + get_session(conn, :admin_did) + ) + + conn + |> renew_session() + |> redirect(to: safe_return_to(return_to(params), ~p"/")) + end + + defp create_local_session(conn, admin_params, params) do + identifier = Map.get(admin_params, "identifier", "") + password = Map.get(admin_params, "password", "") + return_to = return_to(params) + + case AdminAuth.create_local_browser_session(identifier, password) do + {:ok, admin_session} -> + conn + |> renew_session() + |> put_session(:admin_session_id, admin_session.session.id) + |> put_session(:admin_session_family_id, admin_session.family_id) + |> put_session(:admin_did, admin_session.did) + |> redirect(to: safe_return_to(return_to, ~p"/admin")) + + {:error, reason} -> + conn + |> put_status(:unauthorized) + |> render_login(params, login_error(reason)) + end + end + + defp render_login(conn, params, error) do + auth_method = + case AdminAuth.auth_method() do + {:ok, info} -> info.method + {:error, reason} -> reason + end + + render(conn, :new, + form: Phoenix.Component.to_form(%{}, as: :admin), + return_to: safe_return_to(return_to(params), ~p"/"), + error: error, + auth_method: auth_method, + admin_did: configured_admin_did() + ) + end + + defp configured_admin_did do + case AdminAuth.configured_did() do + {:ok, did} -> did + {:error, _reason} -> nil + end + end + + defp return_to(params), do: Map.get(params, "return_to") + + defp safe_return_to(path, fallback) when is_binary(path) do + cond do + path == "" -> fallback + String.starts_with?(path, "//") -> fallback + String.contains?(path, ["\r", "\n"]) -> fallback + String.starts_with?(path, "/") -> path + true -> fallback + end + end + + defp safe_return_to(_path, fallback), do: fallback + + defp login_error(:admin_did_not_configured), do: "TEMPEST_ADMIN_DID is not configured." + defp login_error(:admin_did_not_found), do: "The configured admin DID could not be resolved." + defp login_error(:not_admin_account), do: "This account is not the configured admin DID." + defp login_error(:inactive_account), do: "This account is not active." + defp login_error(:rate_limited), do: "Too many attempts. Try again later." + defp login_error(_reason), do: "The user name or password is incorrect." + + defp renew_session(conn) do + conn + |> configure_session(renew: true) + |> clear_session() + end +end diff --git a/lib/tempest_web/controllers/admin_session_html.ex b/lib/tempest_web/controllers/admin_session_html.ex new file mode 100644 index 0000000..2891f98 --- /dev/null +++ b/lib/tempest_web/controllers/admin_session_html.ex @@ -0,0 +1,5 @@ +defmodule TempestWeb.AdminSessionHTML do + use TempestWeb, :html + + embed_templates "admin_session_html/*" +end diff --git a/lib/tempest_web/controllers/admin_session_html/new.html.heex b/lib/tempest_web/controllers/admin_session_html/new.html.heex new file mode 100644 index 0000000..5efb1bc --- /dev/null +++ b/lib/tempest_web/controllers/admin_session_html/new.html.heex @@ -0,0 +1,55 @@ +
+ +
diff --git a/lib/tempest_web/controllers/operator_account_controller.ex b/lib/tempest_web/controllers/operator_account_controller.ex index addb916..63cffbf 100644 --- a/lib/tempest_web/controllers/operator_account_controller.ex +++ b/lib/tempest_web/controllers/operator_account_controller.ex @@ -97,6 +97,13 @@ defmodule TempestWeb.OperatorAccountController do end defp authenticate(conn) do + case conn.assigns[:account_auth] do + nil -> authenticate_bearer(conn) + auth -> {:ok, auth} + end + end + + defp authenticate_bearer(conn) do conn |> get_req_header("authorization") |> case do diff --git a/lib/tempest_web/live/account_control_live.ex b/lib/tempest_web/live/account_control_live.ex new file mode 100644 index 0000000..0b86f72 --- /dev/null +++ b/lib/tempest_web/live/account_control_live.ex @@ -0,0 +1,130 @@ +defmodule TempestWeb.AccountControlLive do + use TempestWeb, :live_view + + alias Tempest.{Accounts, Blobs, RepoStorage, Security, Sequencer} + alias TempestWeb.OperatorAccountHTML + + @page_limit 50 + + @impl true + def mount(_params, _session, socket), do: {:ok, assign(socket, :page_title, "Account Control Panel")} + + @impl true + def handle_params(params, uri, socket) do + auth = socket.assigns.account_auth + + socket = + socket + |> assign(:current_uri, uri) + |> assign(:account, auth.account) + |> assign_action_data(params) + + {:noreply, socket} + end + + @impl true + def render(assigns) do + ~H""" + + {render_account_page(assigns)} + + """ + end + + defp assign_action_data(%{assigns: %{live_action: :dashboard, account_auth: auth}} = socket, _params) do + {:ok, status} = Accounts.check_account_status(auth) + + socket + |> assign(:status, status) + |> assign(:page_title, "Account Dashboard") + end + + defp assign_action_data(%{assigns: %{live_action: :repo, account: account}} = socket, params) do + {:ok, collections} = RepoStorage.list_collections(account.did) + {:ok, latest} = RepoStorage.latest_commit(account.did) + {:ok, records} = RepoStorage.list_recent_records(account.did, limit: @page_limit) + + socket + |> assign(:collections, collections) + |> assign(:latest, latest) + |> assign(:records, records) + |> assign(:selected_collection, Map.get(params, "collection")) + |> assign(:page_title, "Account Repo") + end + + defp assign_action_data(%{assigns: %{live_action: :blobs, account: account}} = socket, _params) do + {:ok, blobs} = Blobs.list_all(account.did, limit: @page_limit) + + socket + |> assign(:blobs, blobs) + |> assign(:page_title, "Account Blobs") + end + + defp assign_action_data(%{assigns: %{live_action: :access, account: account}} = socket, _params) do + socket + |> assign(:inventory, Security.account_security_inventory(account)) + |> assign(:page_title, "Account Access") + end + + defp assign_action_data(%{assigns: %{live_action: :security, account: account}} = socket, _params) do + socket + |> assign(:inventory, Security.account_security_inventory(account)) + |> assign(:page_title, "Account Security") + end + + defp assign_action_data(%{assigns: %{live_action: :migration, account_auth: auth}} = socket, _params) do + {:ok, status} = Accounts.check_account_status(auth) + + socket + |> assign(:status, status) + |> assign(:page_title, "Account Migration") + end + + defp assign_action_data(%{assigns: %{live_action: :sequencer}} = socket, params) do + cursor = parse_cursor!(Map.get(params, "cursor")) + + {:ok, events} = + Sequencer.list_after(cursor, limit: @page_limit, did: Map.get(params, "did"), type: Map.get(params, "type")) + + socket + |> assign(:events, events) + |> assign(:cursor, cursor) + |> assign(:did_filter, Map.get(params, "did")) + |> assign(:type_filter, Map.get(params, "type")) + |> assign(:page_title, "Account Sequencer") + end + + defp assign_action_data(%{assigns: %{live_action: :firehose, account: account}} = socket, _params) do + {:ok, events} = Sequencer.list_after(0, limit: 20, did: account.did) + + socket + |> assign(:events, events) + |> assign(:websocket_url, websocket_url()) + |> assign(:page_title, "Account Firehose") + end + + defp render_account_page(%{live_action: :dashboard} = assigns), do: OperatorAccountHTML.dashboard(assigns) + defp render_account_page(%{live_action: :repo} = assigns), do: OperatorAccountHTML.repo(assigns) + defp render_account_page(%{live_action: :blobs} = assigns), do: OperatorAccountHTML.blobs(assigns) + defp render_account_page(%{live_action: :access} = assigns), do: OperatorAccountHTML.access(assigns) + defp render_account_page(%{live_action: :security} = assigns), do: OperatorAccountHTML.security(assigns) + defp render_account_page(%{live_action: :migration} = assigns), do: OperatorAccountHTML.migration(assigns) + defp render_account_page(%{live_action: :sequencer} = assigns), do: OperatorAccountHTML.sequencer(assigns) + defp render_account_page(%{live_action: :firehose} = assigns), do: OperatorAccountHTML.firehose(assigns) + + defp parse_cursor!(nil), do: 0 + + defp parse_cursor!(cursor) do + case Integer.parse(cursor) do + {value, ""} when value >= 0 -> value + _other -> 0 + end + end + + defp websocket_url do + config = Tempest.Config.load!() + uri = URI.parse(config.public_url) + scheme = if uri.scheme == "https", do: "wss", else: "ws" + scheme <> "://" <> uri.authority <> "/xrpc/com.atproto.sync.subscribeRepos?cursor=0" + end +end diff --git a/lib/tempest_web/live/admin_control_live.ex b/lib/tempest_web/live/admin_control_live.ex new file mode 100644 index 0000000..87043b1 --- /dev/null +++ b/lib/tempest_web/live/admin_control_live.ex @@ -0,0 +1,66 @@ +defmodule TempestWeb.AdminControlLive do + use TempestWeb, :live_view + + alias Tempest.Admin + alias TempestWeb.AdminHTML + + @impl true + def mount(_params, _session, socket), do: {:ok, assign(socket, :page_title, "Admin Control Panel")} + + @impl true + def handle_params(_params, _uri, socket) do + {:noreply, assign_action_data(socket)} + end + + @impl true + def render(assigns) do + ~H""" + + {render_admin_page(assigns)} + + """ + end + + defp assign_action_data(%{assigns: %{live_action: :dashboard}} = socket) do + socket + |> assign(:status, Admin.status()) + |> assign(:page_title, "Admin Dashboard") + end + + defp assign_action_data(%{assigns: %{live_action: :invites}} = socket) do + socket + |> assign(:status, Admin.status()) + |> assign(:page_title, "Admin Invites") + end + + defp assign_action_data(%{assigns: %{live_action: :repo}} = socket) do + socket + |> assign(:result, nil) + |> assign(:page_title, "Admin Repo") + end + + defp assign_action_data(%{assigns: %{live_action: :backups}} = socket) do + socket + |> assign(:result, nil) + |> assign(:page_title, "Admin Backups") + end + + defp assign_action_data(%{assigns: %{live_action: :storage}} = socket) do + socket + |> assign(:status, Admin.status()) + |> assign(:page_title, "Admin Storage") + end + + defp assign_action_data(%{assigns: %{live_action: :compatibility}} = socket) do + socket + |> assign(:status, Admin.compatibility_status()) + |> assign(:page_title, "Admin Compatibility") + end + + defp render_admin_page(%{live_action: :dashboard} = assigns), do: AdminHTML.dashboard(assigns) + defp render_admin_page(%{live_action: :invites} = assigns), do: AdminHTML.invites(assigns) + defp render_admin_page(%{live_action: :repo} = assigns), do: AdminHTML.repo(assigns) + defp render_admin_page(%{live_action: :backups} = assigns), do: AdminHTML.backups(assigns) + defp render_admin_page(%{live_action: :storage} = assigns), do: AdminHTML.storage(assigns) + defp render_admin_page(%{live_action: :compatibility} = assigns), do: AdminHTML.compatibility(assigns) +end diff --git a/lib/tempest_web/live/control_panel_auth.ex b/lib/tempest_web/live/control_panel_auth.ex new file mode 100644 index 0000000..64d8f7c --- /dev/null +++ b/lib/tempest_web/live/control_panel_auth.ex @@ -0,0 +1,67 @@ +defmodule TempestWeb.ControlPanelAuth do + @moduledoc """ + LiveView auth hooks and session helpers for account and admin control panels. + """ + + import Phoenix.LiveView + import Phoenix.Component, only: [assign: 3] + + alias Tempest.{Accounts, AdminAuth} + + def account_session(conn) do + case conn.assigns[:account_auth] do + %{session: %{id: session_id, family_id: family_id}} -> + %{"account_session_id" => session_id, "account_session_family_id" => family_id} + + _auth -> + %{} + end + end + + def admin_session(conn) do + case conn.assigns[:admin_auth] do + %{session: %{id: session_id, family_id: family_id}, did: did} -> + %{"admin_session_id" => session_id, "admin_session_family_id" => family_id, "admin_did" => did} + + _auth -> + %{} + end + end + + def on_mount(:account, _params, session, socket) do + session_id = session_value(session, "account_session_id", :account_session_id) + family_id = session_value(session, "account_session_family_id", :account_session_family_id) + + case Accounts.authenticate_browser_session(session_id, family_id) do + {:ok, auth} -> + {:cont, + socket + |> assign(:account_auth, auth) + |> assign(:current_scope, %{kind: :account, did: auth.account.did})} + + {:error, _reason} -> + {:halt, redirect(socket, to: "/account/login")} + end + end + + def on_mount(:admin, _params, session, socket) do + session_id = session_value(session, "admin_session_id", :admin_session_id) + family_id = session_value(session, "admin_session_family_id", :admin_session_family_id) + did = session_value(session, "admin_did", :admin_did) + + case AdminAuth.authenticate_browser_session(session_id, family_id, did) do + {:ok, auth} -> + {:cont, + socket + |> assign(:admin_auth, auth) + |> assign(:current_scope, %{kind: :admin, did: did})} + + {:error, _reason} -> + {:halt, redirect(socket, to: "/admin/login")} + end + end + + defp session_value(session, string_key, atom_key) do + Map.get(session, string_key) || Map.get(session, atom_key) + end +end diff --git a/lib/tempest_web/plugs/account_browser_auth.ex b/lib/tempest_web/plugs/account_browser_auth.ex new file mode 100644 index 0000000..71cbaa7 --- /dev/null +++ b/lib/tempest_web/plugs/account_browser_auth.ex @@ -0,0 +1,81 @@ +defmodule TempestWeb.Plugs.AccountBrowserAuth do + @moduledoc """ + Authenticates account Control Panel requests from bearer tokens or browser sessions. + """ + + import Phoenix.Controller + import Plug.Conn + + alias Tempest.Accounts + alias TempestWeb.XrpcErrorJSON + + use TempestWeb, :verified_routes + + def init(opts), do: opts + + def call(conn, _opts) do + case authenticate(conn) do + {:ok, auth} -> + assign(conn, :account_auth, auth) + + {:error, :missing_token} -> + redirect_to_login(conn) + + {:error, :expired_token} -> + reject(conn, 401, "ExpiredToken", "Account session is expired") + + {:error, :inactive_account} -> + reject(conn, 403, "AccountTakedown", "Account is not active") + + {:error, _reason} -> + reject(conn, 401, "InvalidToken", "Account session is invalid") + end + end + + defp authenticate(conn) do + with {:error, :missing_token} <- authenticate_bearer(conn) do + authenticate_session(conn) + end + end + + defp authenticate_bearer(conn) do + conn + |> get_req_header("authorization") + |> case do + ["Bearer " <> token] when token != "" -> Accounts.authenticate_access(token) + ["bearer " <> token] when token != "" -> Accounts.authenticate_access(token) + _headers -> {:error, :missing_token} + end + end + + defp authenticate_session(conn) do + session_id = get_session(conn, :account_session_id) + family_id = get_session(conn, :account_session_family_id) + + case Accounts.authenticate_browser_session(session_id, family_id) do + {:ok, auth} -> + {:ok, auth} + + {:error, _reason} = error when not is_nil(session_id) or not is_nil(family_id) -> + error + + {:error, _reason} -> + {:error, :missing_token} + end + end + + defp redirect_to_login(conn) do + return_to = current_path(conn) + + conn + |> redirect(to: ~p"/account/login?#{[return_to: return_to]}") + |> halt() + end + + defp reject(conn, status, error, message) do + conn + |> clear_session() + |> XrpcErrorJSON.render(status, error, message) + |> halt() + end +end diff --git a/lib/tempest_web/plugs/admin_browser_auth.ex b/lib/tempest_web/plugs/admin_browser_auth.ex new file mode 100644 index 0000000..09e1e24 --- /dev/null +++ b/lib/tempest_web/plugs/admin_browser_auth.ex @@ -0,0 +1,73 @@ +defmodule TempestWeb.Plugs.AdminBrowserAuth do + @moduledoc """ + Authenticates admin Control Panel requests from bearer tokens or browser sessions. + """ + + import Phoenix.Controller + import Plug.Conn + + alias Tempest.AdminAuth + alias TempestWeb.XrpcErrorJSON + + use TempestWeb, :verified_routes + + def init(opts), do: opts + + def call(conn, _opts) do + case authenticate(conn) do + {:ok, auth} -> + assign(conn, :admin_auth, auth) + + {:error, :missing_admin_token} -> + redirect_to_login(conn) + + {:error, :bearer_not_allowed} -> + reject(conn, 401, "AutomationOnly", "Admin bearer tokens are only accepted on automation endpoints") + + {:error, _reason} -> + reject(conn, 401, "InvalidToken", "Admin session is invalid") + end + end + + defp authenticate(conn) do + if authorization_header?(conn) do + {:error, :bearer_not_allowed} + else + authenticate_session(conn) + end + end + + defp authorization_header?(conn), do: conn |> get_req_header("authorization") |> Enum.any?() + + defp authenticate_session(conn) do + session_id = get_session(conn, :admin_session_id) + family_id = get_session(conn, :admin_session_family_id) + did = get_session(conn, :admin_did) + + case AdminAuth.authenticate_browser_session(session_id, family_id, did) do + {:ok, auth} -> + {:ok, auth} + + {:error, _reason} = error when not is_nil(session_id) or not is_nil(family_id) or not is_nil(did) -> + error + + {:error, _reason} -> + {:error, :missing_admin_token} + end + end + + defp redirect_to_login(conn) do + return_to = current_path(conn) + + conn + |> redirect(to: ~p"/admin/login?#{[return_to: return_to]}") + |> halt() + end + + defp reject(conn, status, error, message) do + conn + |> clear_session() + |> XrpcErrorJSON.render(status, error, message) + |> halt() + end +end diff --git a/lib/tempest_web/router.ex b/lib/tempest_web/router.ex index 34f7043..1f15c7c 100644 --- a/lib/tempest_web/router.ex +++ b/lib/tempest_web/router.ex @@ -11,6 +11,14 @@ defmodule TempestWeb.Router do plug :put_secure_browser_headers end + pipeline :account_browser do + plug TempestWeb.Plugs.AccountBrowserAuth + end + + pipeline :admin_browser do + plug TempestWeb.Plugs.AdminBrowserAuth + end + pipeline :api do plug :accepts, ["json"] end @@ -46,23 +54,47 @@ defmodule TempestWeb.Router do live "/changelog", ChangelogLive, :show live "/docs", DocLive, :show live "/docs/:slug", DocLive, :show - get "/account", OperatorAccountController, :dashboard - get "/account/repo", OperatorAccountController, :repo - get "/account/blobs", OperatorAccountController, :blobs - get "/account/access", OperatorAccountController, :access - get "/account/security", OperatorAccountController, :security - get "/account/migration", OperatorAccountController, :migration - get "/account/sequencer", OperatorAccountController, :sequencer - get "/account/firehose", OperatorAccountController, :firehose - - get "/admin", AdminController, :dashboard - get "/admin/invites", AdminController, :invites - get "/admin/repo", AdminController, :repo - post "/admin/repo", AdminController, :repo_action - get "/admin/backups", AdminController, :backups - post "/admin/backups", AdminController, :backup_action - get "/admin/storage", AdminController, :storage - get "/admin/compatibility", AdminController, :compatibility + get "/account/login", AccountSessionController, :new + post "/account/login", AccountSessionController, :create + get "/account/logout", AccountSessionController, :delete + get "/admin/login", AdminSessionController, :new + post "/admin/login", AdminSessionController, :create + get "/admin/logout", AdminSessionController, :delete + end + + scope "/account", TempestWeb do + pipe_through [:browser, :account_browser] + + live_session :account_control_panel, + on_mount: [{TempestWeb.ControlPanelAuth, :account}], + session: {TempestWeb.ControlPanelAuth, :account_session, []} do + live "/", AccountControlLive, :dashboard + live "/repo", AccountControlLive, :repo + live "/blobs", AccountControlLive, :blobs + live "/access", AccountControlLive, :access + live "/security", AccountControlLive, :security + live "/migration", AccountControlLive, :migration + live "/sequencer", AccountControlLive, :sequencer + live "/firehose", AccountControlLive, :firehose + end + end + + scope "/admin", TempestWeb do + pipe_through [:browser, :admin_browser] + + live_session :admin_control_panel, + on_mount: [{TempestWeb.ControlPanelAuth, :admin}], + session: {TempestWeb.ControlPanelAuth, :admin_session, []} do + live "/", AdminControlLive, :dashboard + live "/invites", AdminControlLive, :invites + live "/repo", AdminControlLive, :repo + live "/backups", AdminControlLive, :backups + live "/storage", AdminControlLive, :storage + live "/compatibility", AdminControlLive, :compatibility + end + + post "/repo", AdminController, :repo_action + post "/backups", AdminController, :backup_action end scope "/", TempestWeb do diff --git a/priv/static/images/icons/lock-key.svg b/priv/static/images/icons/lock-key.svg new file mode 100644 index 0000000..b09687e --- /dev/null +++ b/priv/static/images/icons/lock-key.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/test/tempest/config_test.exs b/test/tempest/config_test.exs index 492023e..062bb25 100644 --- a/test/tempest/config_test.exs +++ b/test/tempest/config_test.exs @@ -57,4 +57,16 @@ defmodule Tempest.ConfigTest do ) end end + + test "validates optional admin DID config" do + assert %Tempest.Config{admin_did: "did:plc:abcdefghijklmnopqrstuvwxyz"} = + Tempest.Config.validate!( + Keyword.put(@valid_config, :admin_did, "did:plc:abcdefghijklmnopqrstuvwxyz"), + env: :test + ) + + assert_raise RuntimeError, ~r/admin_did must be a supported DID/, fn -> + Tempest.Config.validate!(Keyword.put(@valid_config, :admin_did, "not-a-did"), env: :test) + end + end end diff --git a/test/tempest_web/controllers/admin_controller_test.exs b/test/tempest_web/controllers/admin_controller_test.exs index b9fc15a..05d8a52 100644 --- a/test/tempest_web/controllers/admin_controller_test.exs +++ b/test/tempest_web/controllers/admin_controller_test.exs @@ -5,6 +5,7 @@ defmodule TempestWeb.AdminControllerTest do setup do old_hash = Application.get_env(:tempest, :admin_token_hash) + old_config = Application.get_env(:tempest, Tempest.Config) on_exit(fn -> if old_hash do @@ -12,6 +13,8 @@ defmodule TempestWeb.AdminControllerTest do else Application.delete_env(:tempest, :admin_token_hash) end + + Application.put_env(:tempest, Tempest.Config, old_config) end) :ok @@ -45,7 +48,7 @@ defmodule TempestWeb.AdminControllerTest do assert response["error"] == "InvalidToken" end - test "admin UI rejects account tokens and renders operator pages", %{conn: conn} do + test "admin UI rejects bearer tokens and browser session renders operator pages", %{conn: conn} do Application.put_env(:tempest, :admin_token_hash, AdminAuth.hash_token("admin-secret-token")) {:ok, session} = @@ -60,7 +63,9 @@ defmodule TempestWeb.AdminControllerTest do |> put_req_header("authorization", "Bearer #{session["accessJwt"]}") |> get(~p"/admin") - assert json_response(rejected, 401)["error"] == "InvalidToken" + assert json_response(rejected, 401)["error"] == "AutomationOnly" + + admin_conn = admin_login_conn(conn, "admin-ui-admin.test", "admin-ui-admin@example.com") for {path, expected} <- [ {~p"/admin", "Admin Dashboard"}, @@ -71,9 +76,8 @@ defmodule TempestWeb.AdminControllerTest do {~p"/admin/compatibility", "Compatibility Status"} ] do html = - conn + admin_conn |> recycle() - |> put_req_header("authorization", "Bearer admin-secret-token") |> get(path) |> html_response(200) @@ -104,4 +108,154 @@ defmodule TempestWeb.AdminControllerTest do assert response["blobStore"]["accountCount"] >= 1 assert [%{"did" => _, "repoCount" => 1} | _] = response["accounts"] end + + test "admin login stores only a browser admin session reference", %{conn: conn} do + {:ok, account} = + Accounts.create_account(%{ + "handle" => "admin-browser.test", + "email" => "admin-browser@example.com", + "password" => "correct horse battery staple" + }) + + configure_admin_did(account["did"]) + + login_page = + conn + |> recycle() + |> get(~p"/admin/login?#{[return_to: "/admin/storage"]}") + |> html_response(200) + + assert login_page =~ ~s(id="admin-login-form") + assert login_page =~ ~s(/images/icons/lock-key.svg) + assert login_page =~ account["did"] + assert login_page =~ "local_account" + + login_conn = + conn + |> recycle() + |> post(~p"/admin/login", %{ + "return_to" => "/admin/storage", + "admin" => %{"identifier" => account["handle"], "password" => "correct horse battery staple"} + }) + + assert redirected_to(login_conn) == ~p"/admin/storage" + assert is_integer(Plug.Conn.get_session(login_conn, :admin_session_id)) + assert is_binary(Plug.Conn.get_session(login_conn, :admin_session_family_id)) + assert Plug.Conn.get_session(login_conn, :admin_did) == account["did"] + + browser_session = Plug.Conn.get_session(login_conn) + refute inspect(browser_session) =~ account["accessJwt"] + refute inspect(browser_session) =~ account["refreshJwt"] + + html = + login_conn + |> recycle() + |> get(~p"/admin/storage") + |> html_response(200) + + assert html =~ "Storage Status" + end + + test "admin login rejects non-admin account sessions", %{conn: conn} do + {:ok, admin} = + Accounts.create_account(%{ + "handle" => "configured-admin.test", + "email" => "configured-admin@example.com", + "password" => "correct horse battery staple" + }) + + {:ok, user} = + Accounts.create_account(%{ + "handle" => "not-admin.test", + "email" => "not-admin@example.com", + "password" => "correct horse battery staple" + }) + + configure_admin_did(admin["did"]) + + conn = + post(conn, ~p"/admin/login", %{ + "admin" => %{"identifier" => user["handle"], "password" => "correct horse battery staple"} + }) + + html = html_response(conn, 401) + assert html =~ "This account is not the configured admin DID." + refute Plug.Conn.get_session(conn, :admin_session_id) + end + + test "admin browser routes redirect to login when no browser session or bearer token exists", %{conn: conn} do + conn = get(conn, ~p"/admin") + assert redirected_to(conn) == ~p"/admin/login?#{[return_to: "/admin"]}" + end + + test "admin bearer token remains available for automation status endpoint", %{conn: conn} do + Application.put_env(:tempest, :admin_token_hash, AdminAuth.hash_token("admin-secret-token")) + + conn = + conn + |> put_req_header("authorization", "Bearer admin-secret-token") + |> get(~p"/xrpc/_admin/status") + + assert json_response(conn, 200)["status"] == "ok" + end + + test "account browser sessions cannot access admin pages", %{conn: conn} do + {:ok, account} = + Accounts.create_account(%{ + "handle" => "account-not-admin.test", + "email" => "account-not-admin@example.com", + "password" => "correct horse battery staple" + }) + + account_conn = + conn + |> recycle() + |> post(~p"/account/login", %{ + "account" => %{"identifier" => account["handle"], "password" => "correct horse battery staple"} + }) + + admin_conn = + account_conn + |> recycle() + |> get(~p"/admin") + + assert redirected_to(admin_conn) == ~p"/admin/login?#{[return_to: "/admin"]}" + end + + test "admin browser sessions do not authorize account-only XRPC methods", %{conn: conn} do + admin_conn = admin_login_conn(conn, "admin-not-account-xrpc.test", "admin-not-account-xrpc@example.com") + + xrpc_conn = + admin_conn + |> recycle() + |> get(~p"/xrpc/com.atproto.server.getSession") + + assert json_response(xrpc_conn, 401)["error"] == "AuthenticationRequired" + end + + defp configure_admin_did(did) do + config = + :tempest + |> Application.fetch_env!(Tempest.Config) + |> Keyword.put(:admin_did, did) + + Application.put_env(:tempest, Tempest.Config, config) + end + + defp admin_login_conn(conn, handle, email) do + {:ok, account} = + Accounts.create_account(%{ + "handle" => handle, + "email" => email, + "password" => "correct horse battery staple" + }) + + configure_admin_did(account["did"]) + + conn + |> recycle() + |> post(~p"/admin/login", %{ + "admin" => %{"identifier" => account["handle"], "password" => "correct horse battery staple"} + }) + end end diff --git a/test/tempest_web/controllers/operator_account_controller_test.exs b/test/tempest_web/controllers/operator_account_controller_test.exs index cf36ff7..d94ca0c 100644 --- a/test/tempest_web/controllers/operator_account_controller_test.exs +++ b/test/tempest_web/controllers/operator_account_controller_test.exs @@ -7,7 +7,7 @@ defmodule TempestWeb.OperatorAccountControllerTest do account = create_account!(conn, "account-ux.test", "account-ux@example.com") unauth_conn = get(conn, ~p"/account") - assert json_response(unauth_conn, 401)["error"] == "AuthenticationRequired" + assert redirected_to(unauth_conn) == ~p"/account/login?#{[return_to: "/account"]}" create_conn = conn @@ -33,6 +33,76 @@ defmodule TempestWeb.OperatorAccountControllerTest do assert_authed_html(conn, account, ~p"/account/firehose", "Firehose Viewer") end + test "account login stores only a browser session reference and authorizes account pages", %{conn: conn} do + account = create_account!(conn, "browser-account.test", "browser-account@example.com") + + login_page = + conn + |> recycle() + |> get(~p"/account/login?#{[return_to: "/account/repo"]}") + |> html_response(200) + + assert login_page =~ ~s(id="account-login-form") + assert login_page =~ ~s(id="account-login-close") + assert login_page =~ ~s(href="/account/repo") + assert login_page =~ ~s(/images/icons/lock-key.svg) + + login_conn = + conn + |> recycle() + |> post(~p"/account/login", %{ + "return_to" => "/account/repo", + "account" => %{"identifier" => account["handle"], "password" => @password} + }) + + assert redirected_to(login_conn) == ~p"/account/repo" + + browser_session = Plug.Conn.get_session(login_conn) + assert is_integer(Plug.Conn.get_session(login_conn, :account_session_id)) + assert is_binary(Plug.Conn.get_session(login_conn, :account_session_family_id)) + assert Plug.Conn.get_session(login_conn, :account_did) == account["did"] + refute Map.has_key?(browser_session, :accessJwt) + refute Map.has_key?(browser_session, :refreshJwt) + refute inspect(browser_session) =~ account["accessJwt"] + refute inspect(browser_session) =~ account["refreshJwt"] + + html = + login_conn + |> recycle() + |> get(~p"/account/repo") + |> html_response(200) + + assert html =~ "Repo Browser" + assert html =~ account["did"] + end + + test "account logout clears browser-session access", %{conn: conn} do + account = create_account!(conn, "logout-account.test", "logout-account@example.com") + + login_conn = + conn + |> recycle() + |> post(~p"/account/login", %{ + "account" => %{"identifier" => account["handle"], "password" => @password} + }) + + assert redirected_to(login_conn) == ~p"/account" + + logout_conn = + login_conn + |> recycle() + |> get(~p"/account/logout?#{[return_to: "/stats"]}") + + assert redirected_to(logout_conn) == ~p"/stats" + + redirected_conn = + logout_conn + |> recycle() + |> get(~p"/account") + + assert redirected_to(redirected_conn) == ~p"/account/login?#{[return_to: "/account"]}" + end + defp assert_authed_html(conn, account, path, expected) do html = conn -- 2.51.2