From 30d2f31d12b857ac2c04f0e1a2e5b5f69725c5fa Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 12 Jun 2026 16:35:55 -0500 Subject: [PATCH] test: black-box local server/sdk tests * document PLC compatibility coverage and task status --- docs/specs/README.md | 2 + docs/specs/doc-viewer.md | 242 ++++++++++++ docs/specs/identity-handles.md | 26 +- docs/specs/migration-lifecycle.md | 18 + docs/specs/pds-compatibility.md | 5 + docs/specs/public-stats-dashboard.md | 238 ++++++++++++ docs/tasks/03-identity-handles.md | 16 + docs/tasks/12-migration-lifecycle.md | 12 + docs/tasks/14-local-pds-compatibility.md | 8 +- docs/tasks/16-public-stats-dashboard.md | 93 +++++ docs/tasks/17-doc-viewer.md | 81 ++++ docs/tasks/README.md | 35 +- test/support/atproto_sdk_client.ex | 93 +++++ .../tempest/interop/local_server_sdk_test.exs | 339 +++++++++++++++++ .../xrpc/compatibility_auth_content_test.exs | 349 ++++++++++++++++++ 15 files changed, 1532 insertions(+), 25 deletions(-) create mode 100644 docs/specs/doc-viewer.md create mode 100644 docs/specs/public-stats-dashboard.md create mode 100644 docs/tasks/16-public-stats-dashboard.md create mode 100644 docs/tasks/17-doc-viewer.md create mode 100644 test/support/atproto_sdk_client.ex create mode 100644 test/tempest/interop/local_server_sdk_test.exs create mode 100644 test/tempest_web/xrpc/compatibility_auth_content_test.exs diff --git a/docs/specs/README.md b/docs/specs/README.md index a622cd4..1d09603 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -30,6 +30,8 @@ Subsystem specifications live in this directory. 14. [Deployment and Observability](./deployment-observability.md) 15. [Interop and Integration Testing](./interop-testing.md) 16. [PDS Compatibility Against Reference Surface](./pds-compatibility.md) +17. [Public Stats Dashboard](./public-stats-dashboard.md) +18. [Documentation Viewer](./doc-viewer.md) ## Source Baseline diff --git a/docs/specs/doc-viewer.md b/docs/specs/doc-viewer.md new file mode 100644 index 0000000..030dece --- /dev/null +++ b/docs/specs/doc-viewer.md @@ -0,0 +1,242 @@ +--- +title: Documentation Viewer +updated: 2026-06-12 +status: planned +--- + +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 + +Recommended modules: + +- `Tempest.Docs` context for manifest lookup, file loading, frontmatter parsing, + Markdown rendering, and link rewriting. +- `TempestWeb.DocController` for `index` and `show` actions. +- `TempestWeb.DocHTML` with `index.html.heex` or `show.html.heex`. + +Recommended route placement: + +```elixir +scope "/", TempestWeb do + pipe_through :browser + + get "/docs", DocController, :index + get "/docs/:slug", DocController, :show +end +``` + +The controller should pass assigns such as: + +- `: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. Initial implementation can read at request time in +`dev` and cache in memory in `prod`. A later version can add ETags or a manifest +checksum. + +A simple first-pass policy: + +- `dev`: read every request for fast documentation iteration +- `test`: read every request +- `prod`: cache rendered docs in `:persistent_term` or a supervised GenServer + +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`. + +## HTTP 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 +``` diff --git a/docs/specs/identity-handles.md b/docs/specs/identity-handles.md index b403310..52b4c69 100644 --- a/docs/specs/identity-handles.md +++ b/docs/specs/identity-handles.md @@ -68,12 +68,30 @@ The boundary must make it possible to test against a fake PLC service. Migration-ready identity support requires: -- `getRecommendedDidCredentials` returning this PDS service endpoint, signing key, handle, and PLC rotation-key recommendations. -- `requestPlcOperationSignature` and `signPlcOperation` with a separate security token factor. -- `submitPlcOperation` validating that the operation keeps the account recoverable and points the atproto service at Tempest before submission. -- `reserveSigningKey` for migration flows that need stable key material before the account is activated. +- `com.atproto.identity.getRecommendedDidCredentials` returning this PDS service endpoint, signing key, handle, and PLC rotation-key recommendations. +- `com.atproto.identity.requestPlcOperationSignature` and `com.atproto.identity.signPlcOperation` with a separate security token factor. +- `com.atproto.identity.submitPlcOperation` validating that the operation keeps the account recoverable and points the atproto service at Tempest before submission. +- `com.atproto.server.reserveSigningKey` for migration flows that need stable key material before the account is activated. - `did:web` support for both PDS-hosted subdomains and bring-your-own domains. +### PLC XRPC Endpoint Coverage + +Tempest must expose the PLC identity XRPC methods as first-class compatibility +endpoints, not only as internal PLC client helpers: + +| Method | Auth | Required local coverage | +|---|---|---| +| `com.atproto.identity.getRecommendedDidCredentials` | bearer access | response includes DID, handle, atproto signing key, service endpoint, and recommended rotation keys for the authenticated account | +| `com.atproto.identity.requestPlcOperationSignature` | bearer access + strong reauth challenge | creates an auditable, single-use PLC operation signature request without submitting an operation | +| `com.atproto.identity.signPlcOperation` | bearer access + valid reauth token | signs only operations that preserve account recoverability and Tempest service routing | +| `com.atproto.identity.submitPlcOperation` | bearer access + signed operation | submits through `Tempest.Identity.PlcClient`, rejects operations that remove Tempest as PDS, and records success/failure for migration audit | + +Coverage must include response shapes, protocol error shapes, auth failures, +app-password/OAuth denial for recovery-sensitive actions, and fake-PLC boundary +tests. These endpoints are account-recovery operations; app passwords and broad +OAuth scopes must not authorize them unless a future spec explicitly defines a +separate high-assurance delegated scope. + See [Migration and Account Lifecycle](./migration-lifecycle.md) for account activation sequencing. ## Adversarial Checks diff --git a/docs/specs/migration-lifecycle.md b/docs/specs/migration-lifecycle.md index dc82bb5..8a952b2 100644 --- a/docs/specs/migration-lifecycle.md +++ b/docs/specs/migration-lifecycle.md @@ -39,6 +39,24 @@ com.atproto.server.requestPasswordReset com.atproto.server.resetPassword ``` +## PLC Endpoint Compatibility + +Migration-in and migration-out require public coverage for these identity XRPC +methods in addition to the internal PLC client boundary: + +```text +com.atproto.identity.getRecommendedDidCredentials +com.atproto.identity.requestPlcOperationSignature +com.atproto.identity.signPlcOperation +com.atproto.identity.submitPlcOperation +``` + +Tests must use a fake PLC service and prove that invalid, unsigned, stale, or +service-diverting operations fail closed. Successful submissions must preserve +rotation-key recoverability, keep `#atproto_pds` pointed at Tempest when the +account is active here, and emit/audit identity lifecycle changes in migration +order. + ## Migration Flow Supported happy path: diff --git a/docs/specs/pds-compatibility.md b/docs/specs/pds-compatibility.md index 9e8bf20..ba39393 100644 --- a/docs/specs/pds-compatibility.md +++ b/docs/specs/pds-compatibility.md @@ -6,6 +6,11 @@ status: implemented Reference documentation: ../reference/pds-compatibility.md +PLC endpoint coverage is tracked explicitly by `identity-handles.md` and the +compatibility matrix. The PLC XRPC methods stay `planned` until they have +registered handlers, bundled Lexicons, response/error shape tests, auth denial +coverage for app passwords/OAuth, and fake PLC submission tests. + Verification: ```bash diff --git a/docs/specs/public-stats-dashboard.md b/docs/specs/public-stats-dashboard.md new file mode 100644 index 0000000..d405fd5 --- /dev/null +++ b/docs/specs/public-stats-dashboard.md @@ -0,0 +1,238 @@ +--- +title: Public Stats Dashboard +updated: 2026-06-12 +status: planned +--- + +Tempest is an experimental PDS, so aggregate operational statistics can be public. +The public dashboard should make the node feel inspectable without exposing account +secrets, auth material, private tokens, internal filesystem paths, or per-user admin +controls. + +Reference documentation: + +- [Admin Operations](./admin-operations.md) +- [Deployment and Observability](./deployment-observability.md) +- [Sync and Firehose](./sync-firehose.md) +- [SQLite Storage](./storage-sqlite.md) + +## Goals + +- Publish a public, no-auth stats page for the node. +- Publish a small public JSON stats endpoint for automation and badges. +- Reuse existing SQLite, sequencer, and storage status data before adding new + persistence. +- Keep the private admin dashboard for maintenance actions, backups, repo import, + repo export, and sensitive paths. +- Make freshness visible: users should know when the stats were computed and what + `lastIndexedAt` means. + +## Non-goals + +- No public account emails, app passwords, sessions, OAuth grants, security events, + backup paths, admin token state, or raw local filesystem paths. +- No unauthenticated admin actions. +- No promise of hosted-provider scale metrics. Initial implementation may scan + per-repo SQLite files on request or with a short cache. +- No external analytics dependency is required for the first version. + +## Public surfaces + +### HTML dashboard + +Add a public route, preferably: + +```text +GET /stats +``` + +The page should be linked from the home page and can use the existing Tempest visual +system. It should show high-level cards first, then optional detail tables. + +### JSON endpoint + +Add a public route, preferably: + +```text +GET /xrpc/_stats +``` + +The response should be stable enough for scripts but explicitly project-local, not +an AT Protocol standard endpoint. + +Example shape: + +```json +{ + "status": "ok", + "version": "0.1.0", + "generatedAt": "2026-06-12T19:00:00Z", + "uptimeSeconds": 86400, + "metrics": { + "hostedAccountCount": 3, + "totalAccountCount": 4, + "commitCount": 128, + "collectionCount": 12, + "recordCount": 2048, + "lastIndexedAt": "2026-06-12T18:57:11Z" + }, + "health": { + "status": "ok", + "checks": { + "storageWritable": true, + "accountDatabase": "ok", + "sequencerDatabase": "ok", + "repoDirectory": "ok", + "blobDirectory": "ok", + "sequencerReadable": true, + "tornWriteCount": 0 + } + } +} +``` + +## Metric definitions + +### `hostedAccountCount` + +Count accounts where the account is hosted and active on this PDS. For the current +schema, use accounts with: + +```elixir +account.active == true and account.status == "active" +``` + +### `totalAccountCount` + +Count all account rows, including deactivated, suspended, takendown, and deleted +states. This helps explain why totals may differ from hosted active accounts. + +### `commitCount` + +Initial definition: sum the number of rows in each hosted repo SQLite `commits` +table. + +A later version may also expose `sequencedCommitCount` from the global sequencer: + +```sql +SELECT COUNT(*) FROM repo_seq WHERE event_type = '#commit' +``` + +If both are displayed, label them clearly: + +- repo commits: commits present in per-account repo stores +- sequenced commits: commit events published to the firehose sequence + +### `collectionCount` + +Initial definition: sum the number of distinct current record collections across +hosted repos: + +```sql +SELECT COUNT(DISTINCT collection) FROM records +``` + +This counts collections per repo. If two accounts both have `app.bsky.feed.post`, +the aggregate count increases by two. A later dashboard can also show global unique +collection NSIDs. + +### `recordCount` + +Count current records in per-repo `records` tables: + +```sql +SELECT COUNT(*) FROM records +``` + +Deleted historical records are not counted unless a future historical event metric +is added. + +### `lastIndexedAt` + +For this project dashboard, `lastIndexedAt` means the newest timestamp at which the +PDS accepted, imported, committed, or sequenced repo-visible data. + +Initial implementation should compute the max of available timestamps: + +- newest `records.updated_at` across repo DBs +- newest `commits.inserted_at` across repo DBs +- newest `repo_seq.created_at` from the sequencer DB + +The dashboard should label this as "last indexed" but include helper text: + +> Latest local repo, commit, or sequencer activity observed by this PDS. + +## Health definitions + +Health should be public and conservative. It should not disclose private local +paths in production. + +Statuses: + +- `ok`: required storage and DB checks pass and torn writes are zero. +- `degraded`: service can respond, but a non-critical check failed or one or more + repo DBs could not be scanned for stats. +- `unhealthy`: account DB, sequencer DB, or writable storage checks fail. + +Recommended checks: + +- account DB exists and can answer a simple query +- sequencer DB exists and can answer `current_seq` +- data directory is writable +- repo directory exists +- blob directory exists +- torn write count is zero +- stats scan error count is zero or explicitly reported + +## Privacy and safety + +Public stats may include: + +- aggregate counts +- public DIDs and handles only if a detail table is explicitly designed for that + purpose +- repo status values already visible through public protocol behavior +- public health summary + +Public stats must not include: + +- emails +- password/session/token/OAuth state +- admin token configuration +- backup paths +- raw local filesystem paths in production +- private key state +- security event metadata + +## Implementation notes + +Prefer a new context boundary such as `Tempest.PublicStats` or a narrow function +under `Tempest.Admin` that returns sanitized public data. The admin status map is +close to the required data, but it contains fields that should not be copied to a +public endpoint without filtering. + +Recommended first pass: + +1. Extend `Tempest.RepoStorage.status_counts/2` or add a sibling function that + returns `record_count`, `commit_count`, `collection_count`, and latest relevant + timestamps for one repo. +2. Add an aggregate public stats function that scans active accounts and folds the + per-repo stats. +3. Add uptime tracking using application start monotonic time. +4. Add `/xrpc/_stats` JSON. +5. Add `/stats` HTML. +6. Add tests and Hurl smoke coverage. + +If request-time scans become expensive, add a supervised cache with a short TTL +such as 5-30 seconds. The JSON response should include `generatedAt` so clients can +judge freshness. + +## HTTP verification + +```bash +curl -fsS http://localhost:4000/xrpc/_stats +curl -fsS http://localhost:4000/stats +hurl --test --jobs 1 \ + --variable base_url=http://localhost:4000 \ + test/smoke/public-stats.hurl +``` diff --git a/docs/tasks/03-identity-handles.md b/docs/tasks/03-identity-handles.md index ae8e6d8..53f5dd4 100644 --- a/docs/tasks/03-identity-handles.md +++ b/docs/tasks/03-identity-handles.md @@ -20,3 +20,19 @@ hurl --test --jobs 1 \ This milestone covers local identity and handle behavior. Network identity correctness for hosted DIDs is tracked in Milestone 11. + +## Follow-up PLC Endpoint Coverage + +The internal PLC client boundary exists, but public PLC identity XRPC endpoints +remain follow-up work: + +- [ ] Add `com.atproto.identity.getRecommendedDidCredentials` with response-shape, + auth, and fake-PLC/key-store tests. +- [ ] Add `com.atproto.identity.requestPlcOperationSignature` with strong reauth, + single-use token, audit-log, and error-shape tests. +- [ ] Add `com.atproto.identity.signPlcOperation` with operation validation tests + that reject service-diverting or unrecoverable PLC operations. +- [ ] Add `com.atproto.identity.submitPlcOperation` with fake PLC submission, + failure, idempotency, and migration event-ordering tests. +- [ ] Refresh bundled Lexicons and the PDS compatibility matrix when the handlers + are registered. diff --git a/docs/tasks/12-migration-lifecycle.md b/docs/tasks/12-migration-lifecycle.md index 5e866f1..98e337c 100644 --- a/docs/tasks/12-migration-lifecycle.md +++ b/docs/tasks/12-migration-lifecycle.md @@ -23,3 +23,15 @@ post-import revision monotonicity, activation event ordering, unavailable old PD failure, and self-controlled `did:web` activation. Reference documentation: [Migration and Account Lifecycle](../reference/migration-lifecycle.md). + +## Follow-up PLC Migration Endpoint Coverage + +- [ ] Cover migration-out through `com.atproto.identity.getRecommendedDidCredentials`, + `requestPlcOperationSignature`, `signPlcOperation`, and + `submitPlcOperation` using a fake PLC service. +- [ ] Prove PLC operations cannot activate or migrate accounts unless the DID + document preserves recoverability and points `#atproto_pds` at the intended + service endpoint. +- [ ] Add black-box tests for PLC endpoint auth: session bearer allowed with + strong reauth; app passwords, ordinary OAuth tokens, admin tokens, and + missing credentials denied. diff --git a/docs/tasks/14-local-pds-compatibility.md b/docs/tasks/14-local-pds-compatibility.md index 51c8c27..ffe040b 100644 --- a/docs/tasks/14-local-pds-compatibility.md +++ b/docs/tasks/14-local-pds-compatibility.md @@ -17,12 +17,12 @@ coverage and use SDK tests where client behavior matters. behavior and reference Lexicons. - [x] T14-02: Add ConnCase response-shape and error-shape tests for core PDS endpoints. -- [ ] T14-03: Add ConnCase auth tests for bearer tokens, app passwords, OAuth +- [x] T14-03: Add ConnCase auth tests for bearer tokens, app passwords, OAuth tokens, admin tokens, and missing credentials. -- [ ] T14-04: Add ConnCase content-type and verb tests for XRPC endpoints. -- [ ] T14-05: Add SDK black-box tests for login, write, read, blob, CAR, and +- [x] T14-04: Add ConnCase content-type and verb tests for XRPC endpoints. +- [x] T14-05: Add SDK black-box tests for login, write, read, blob, CAR, and firehose flows against a local server. -- [ ] T14-06: Add OAuth and app-password black-box compatibility tests. +- [x] T14-06: Add OAuth and app-password black-box compatibility tests. - [ ] T14-07: Add migration-in and migration-out compatibility tests using two local Tempest instances. - [ ] T14-08: Add an explicit AppView proxy/fallback policy and local coverage diff --git a/docs/tasks/16-public-stats-dashboard.md b/docs/tasks/16-public-stats-dashboard.md new file mode 100644 index 0000000..3f430b4 --- /dev/null +++ b/docs/tasks/16-public-stats-dashboard.md @@ -0,0 +1,93 @@ +--- +title: Milestone 16 - Public Stats Dashboard +specs: + - ../specs/public-stats-dashboard.md + - ../specs/deployment-observability.md + - ../specs/admin-operations.md +references: + - ../reference/deployment-observability.md + - ../reference/admin-operations.md +--- + +Goal: expose safe public aggregate stats for the experimental Tempest PDS while +keeping admin-only operations and sensitive internals private. + +- [ ] T16-01: Add a public stats context or sanitized stats function. + It should not reuse the full private admin status response directly. +- [ ] T16-02: Extend repo stats to include per-repo `commit_count`, + `collection_count`, and latest repo activity timestamps. +- [ ] T16-03: Add aggregate counts for hosted accounts, total accounts, commits, + collections, records, and `lastIndexedAt`. +- [ ] T16-04: Add application uptime tracking based on monotonic time recorded at + application start. +- [ ] T16-05: Add a public health summary with `ok`, `degraded`, and `unhealthy` + states. +- [ ] T16-06: Add `GET /xrpc/_stats` returning sanitized public JSON. +- [ ] T16-07: Add `GET /stats` public HTML dashboard. +- [ ] T16-08: Link the public stats dashboard from the home page. +- [ ] T16-09: Add dashboard cards for hosted accounts, commits, collections, + records, last indexed, uptime, and health. +- [ ] T16-10: Add helper copy explaining that `lastIndexedAt` is local repo, + commit, or sequencer activity observed by this PDS. +- [ ] T16-11: Add ConnCase tests for `/stats` and `/xrpc/_stats` without admin + authorization. +- [ ] T16-12: Add regression tests proving public stats do not include email, + token, session, OAuth, backup path, admin token, or private filesystem data. +- [ ] T16-13: Add Hurl smoke test `test/smoke/public-stats.hurl`. +- [ ] T16-14: Document cache behavior if stats are cached. Include `generatedAt` + in the JSON response either way. + +## 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. + +## HTTP Verification + +```bash +curl -fsS http://localhost:4000/xrpc/_stats +curl -fsS http://localhost:4000/stats +hurl --test --jobs 1 \ + --variable base_url=http://localhost:4000 \ + 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`. + +Suggested JSON shape: + +```json +{ + "status": "ok", + "version": "0.1.0", + "generatedAt": "2026-06-12T19:00:00Z", + "uptimeSeconds": 86400, + "metrics": { + "hostedAccountCount": 3, + "totalAccountCount": 4, + "commitCount": 128, + "collectionCount": 12, + "recordCount": 2048, + "lastIndexedAt": "2026-06-12T18:57:11Z" + }, + "health": { + "status": "ok", + "checks": { + "storageWritable": true, + "accountDatabase": "ok", + "sequencerDatabase": "ok", + "repoDirectory": "ok", + "blobDirectory": "ok", + "sequencerReadable": true, + "tornWriteCount": 0 + } + } +} +``` diff --git a/docs/tasks/17-doc-viewer.md b/docs/tasks/17-doc-viewer.md new file mode 100644 index 0000000..b561133 --- /dev/null +++ b/docs/tasks/17-doc-viewer.md @@ -0,0 +1,81 @@ +--- +title: Milestone 17 - Doc Viewer +specs: + - ../specs/doc-viewer.md + - ../specs/deployment-observability.md +references: + - ../reference/README.md + - ../reference/architecture.md +--- + +Goal: publish `docs/reference/` as a public Phoenix documentation site with a +Web 1.0 / Netscape Navigator-inspired UI. + +- [ ] T17-01: Add a `Tempest.Docs` context with a fixed manifest for files under + `docs/reference/`. +- [ ] T17-02: Add safe document lookup by slug. Reject unknown slugs and any path + traversal attempt. +- [ ] T17-03: Add frontmatter parsing for `title` and `updated`, falling back to + manifest values when frontmatter is missing. +- [ ] T17-04: Add server-side Markdown rendering for trusted local reference docs. + Decide whether to use a dependency such as `MDEx`/`Earmark` or a small local + subset renderer. +- [ ] T17-05: Add relative-link rewriting for links between known reference docs. +- [ ] T17-06: Add `TempestWeb.DocController` with `index` and `show` actions. +- [ ] T17-07: Add public routes `GET /docs` and `GET /docs/:slug` under the + browser pipeline. +- [ ] T17-08: Add `TempestWeb.DocHTML` templates for the doc viewer. +- [ ] T17-09: Build the Netscape-style chrome: title bar, toolbar buttons, + location bar, bookmarks pane, document pane, and footer. +- [ ] 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`). +- [ ] T17-11: Add accessible focus, contrast, heading, and navigation behavior. +- [ ] T17-12: Add previous/next document links based on manifest order. +- [ ] T17-13: Link the docs viewer from the home page and any relevant public + navigation. +- [ ] T17-14: Add ConnCase tests for `/docs`, `/docs/architecture`, unknown slugs, + sidebar navigation, relative-link rewriting, and path traversal rejection. +- [ ] T17-15: Add regression tests proving files outside `docs/reference/` cannot + be rendered. +- [ ] T17-16: Add Hurl smoke test `test/smoke/doc-viewer.hurl`. +- [ ] T17-17: Add production caching or explicitly document request-time rendering + if the first implementation skips 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 + +```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 +``` + +## 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 + +Keep it semantic and responsive. Do not use actual framesets. Do not use inline +scripts. Do not reference external vendored assets from layouts. diff --git a/docs/tasks/README.md b/docs/tasks/README.md index b047ac9..341279f 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -4,22 +4,24 @@ title: Milestone Tasks ## Milestones -1. [00 Foundation](./00-foundation.md) -2. [01 XRPC Shell](./01-xrpc-shell.md) -3. [02 Accounts and Sessions](./02-accounts-sessions.md) -4. [03 Identity and Handles](./03-identity-handles.md) -5. [04 Repository Core](./04-repo-core.md) -6. [05 Record APIs](./05-record-apis.md) -7. [06 CAR and Sync Reads](./06-car-sync-reads.md) -8. [07 Firehose](./07-firehose.md) -9. [08 Blobs](./08-blobs.md) -10. [09 Lexicon Schemas](./09-lexicon-schemas.md) -11. [10 Compatibility Hardening](./10-compatibility-hardening.md) -12. [11 Security, OAuth, and Delegated Access](./11-security-oauth.md) -13. [12 Migration and Account Lifecycle](./12-migration-lifecycle.md) -14. [13 Admin, Storage, and Operator Features](./13-admin-operator-features.md) -15. [14 Local PDS Compatibility Testing](./14-local-pds-compatibility.md) -16. [15 Deployment and Post-deployment Verification](./15-deployment-verification.md) +1. [Foundation](./00-foundation.md) +2. [XRPC Shell](./01-xrpc-shell.md) +3. [Accounts and Sessions](./02-accounts-sessions.md) +4. [Identity and Handles](./03-identity-handles.md) +5. [Repository Core](./04-repo-core.md) +6. [Record APIs](./05-record-apis.md) +7. [CAR and Sync Reads](./06-car-sync-reads.md) +8. [Firehose](./07-firehose.md) +9. [Blobs](./08-blobs.md) +10. [Lexicon Schemas](./09-lexicon-schemas.md) +11. [Compatibility Hardening](./10-compatibility-hardening.md) +12. [Security, OAuth, and Delegated Access](./11-security-oauth.md) +13. [Migration and Account Lifecycle](./12-migration-lifecycle.md) +14. [Admin, Storage, and Operator Features](./13-admin-operator-features.md) +15. [Local PDS Compatibility Testing](./14-local-pds-compatibility.md) +16. [Deployment and Post-deployment Verification](./15-deployment-verification.md) +17. [Public Stats Dashboard](./16-public-stats-dashboard.md) +18. [Doc Viewer](./17-doc-viewer.md) Each file in this directory is a milestone. Each task is intended to be the smallest useful unit of work: one focused implementation change, test, or @@ -47,7 +49,6 @@ Deprioritized behind the above for this profile: - MFA and advanced account/security UX (parts of Milestone 11/14) - hosted-provider scale features - ## Status Labels Use these labels when work starts: diff --git a/test/support/atproto_sdk_client.ex b/test/support/atproto_sdk_client.ex new file mode 100644 index 0000000..fd4d514 --- /dev/null +++ b/test/support/atproto_sdk_client.ex @@ -0,0 +1,93 @@ +defmodule Tempest.AtprotoSdkClient do + @moduledoc """ + Tiny HTTP-only AT Protocol client used by local-server compatibility tests. + + It intentionally talks to Tempest through public HTTP endpoints instead of + calling contexts, so tests exercise black-box client behavior without adding a + Node or Python SDK dependency to the Mix project. + """ + + defstruct [:base_url] + + def new(base_url) when is_binary(base_url), do: %__MODULE__{base_url: String.trim_trailing(base_url, "/")} + + def create_account(client, attrs), do: post_json(client, "/xrpc/com.atproto.server.createAccount", attrs) + + def create_session(client, identifier, password) do + post_json(client, "/xrpc/com.atproto.server.createSession", %{"identifier" => identifier, "password" => password}) + end + + def create_record(client, token, attrs, headers \\ []) do + post_json(client, "/xrpc/com.atproto.repo.createRecord", attrs, auth_headers(token) ++ headers) + end + + def get_record(client, params) do + get_json(client, "/xrpc/com.atproto.repo.getRecord", params) + end + + def upload_blob(client, token, bytes, content_type \\ "text/plain") when is_binary(bytes) do + response = + Req.post!(url(client, "/xrpc/com.atproto.repo.uploadBlob"), + headers: auth_headers(token) ++ [{"content-type", content_type}], + body: bytes + ) + + decode_response(response) + end + + def get_repo(client, did) do + Req.get!(url(client, "/xrpc/com.atproto.sync.getRepo"), params: %{"did" => did}) + end + + def get_blob(client, did, cid) do + Req.get!(url(client, "/xrpc/com.atproto.sync.getBlob"), params: %{"did" => did, "cid" => cid}) + end + + def create_app_password(client, token, attrs) do + post_json(client, "/xrpc/com.atproto.server.createAppPassword", attrs, auth_headers(token)) + end + + def oauth_par(client, attrs, dpop) do + post_form(client, "/oauth/par", attrs, [{"dpop", dpop}]) + end + + def oauth_authorize(client, attrs) do + Req.post!(url(client, "/oauth/authorize"), form: attrs, redirect: false) + end + + def oauth_token(client, attrs, dpop) do + post_form(client, "/oauth/token", attrs, [{"dpop", dpop}]) + end + + def oauth_revoke(client, attrs) do + Req.post!(url(client, "/oauth/revoke"), form: attrs) + end + + defp get_json(client, path, params) do + client + |> url(path) + |> Req.get!(params: params) + |> decode_response() + end + + defp post_json(client, path, body, headers \\ []) do + client + |> url(path) + |> Req.post!(json: body, headers: headers) + |> decode_response() + end + + defp post_form(client, path, body, headers) do + client + |> url(path) + |> Req.post!(form: body, headers: headers) + |> decode_response() + end + + defp decode_response(%Req.Response{status: status, body: body, headers: headers}) do + %{status: status, body: body, headers: headers} + end + + defp auth_headers(token), do: [{"authorization", "Bearer #{token}"}] + defp url(client, path), do: client.base_url <> path +end diff --git a/test/tempest/interop/local_server_sdk_test.exs b/test/tempest/interop/local_server_sdk_test.exs new file mode 100644 index 0000000..f2696d2 --- /dev/null +++ b/test/tempest/interop/local_server_sdk_test.exs @@ -0,0 +1,339 @@ +defmodule Tempest.Interop.LocalServerSdkTest do + use ExUnit.Case, async: false + + import Bitwise + + alias Tempest.AtprotoSdkClient + alias Tempest.OAuth.Dpop + alias Tempest.RepoCore.{Car, Cid, Drisl} + + @base_url "http://localhost:4002" + @password "correct horse battery staple" + @client_id "did:web:local-sdk-client.example.com" + @redirect_uri "https://local-sdk-client.example.com/cb" + + setup do + Tempest.DataCase.setup_sandbox(%{async: false}) + + start_supervised!({Bandit, plug: TempestWeb.Endpoint, scheme: :http, ip: {127, 0, 0, 1}, port: 4002}) + + {:ok, client: AtprotoSdkClient.new(@base_url)} + end + + test "SDK-style local client can login, write, read, upload blobs, export CAR, and observe firehose", %{ + client: client + } do + suffix = System.unique_integer([:positive]) + handle = "sdk-#{suffix}.test" + email = "sdk-#{suffix}@example.com" + + {:ok, firehose_cursor} = Tempest.Sequencer.current_seq() + + assert %{status: 200, body: account} = + AtprotoSdkClient.create_account(client, %{ + "handle" => handle, + "email" => email, + "password" => @password + }) + + assert %{"did" => did, "accessJwt" => access_jwt} = account + + assert %{status: 200, body: %{"did" => ^did, "accessJwt" => login_access_jwt}} = + AtprotoSdkClient.create_session(client, handle, @password) + + assert is_binary(login_access_jwt) + + assert %{status: 200, body: created} = + AtprotoSdkClient.create_record(client, access_jwt, %{ + "repo" => did, + "collection" => "app.tempest.note", + "rkey" => "sdk-note", + "validate" => false, + "record" => %{"$type" => "app.tempest.note", "text" => "written by local SDK client"} + }) + + assert %{"uri" => "at://" <> _, "cid" => record_cid, "commit" => %{"cid" => commit_cid}} = created + + assert %{status: 200, body: record} = + AtprotoSdkClient.get_record(client, %{ + "repo" => did, + "collection" => "app.tempest.note", + "rkey" => "sdk-note" + }) + + assert record["cid"] == record_cid + assert record["value"]["text"] == "written by local SDK client" + + assert %{status: 200, body: upload} = AtprotoSdkClient.upload_blob(client, access_jwt, "blob bytes") + blob_cid = upload["blob"]["ref"]["$link"] + + assert %{status: 200} = + AtprotoSdkClient.create_record(client, access_jwt, %{ + "repo" => did, + "collection" => "app.tempest.blob", + "rkey" => "sdk-blob", + "validate" => false, + "record" => %{ + "$type" => "app.tempest.blob", + "image" => %{ + "$type" => "blob", + "ref" => %{"$link" => blob_cid}, + "mimeType" => "text/plain", + "size" => byte_size("blob bytes") + } + } + }) + + assert %{status: 200, body: "blob bytes"} = AtprotoSdkClient.get_blob(client, did, blob_cid) + + repo_response = AtprotoSdkClient.get_repo(client, did) + assert repo_response.status == 200 + assert get_header(repo_response.headers, "content-type") =~ "application/vnd.ipld.car" + assert {:ok, car} = Car.decode(repo_response.body) + assert Enum.any?(car.blocks, &(Cid.to_string(&1.cid) == record_cid)) + assert Enum.any?(car.blocks, &(Cid.to_string(&1.cid) == commit_cid)) + + frames = websocket_backfill_frames("/xrpc/com.atproto.sync.subscribeRepos?cursor=#{firehose_cursor}", 10) + assert Enum.any?(frames, &commit_frame?(&1, did)) + end + + test "OAuth and app-password compatibility flows work through public HTTP", %{client: client} do + suffix = System.unique_integer([:positive]) + handle = "sdk-auth-#{suffix}.test" + + %{status: 200, body: account} = + AtprotoSdkClient.create_account(client, %{ + "handle" => handle, + "email" => "sdk-auth-#{suffix}@example.com", + "password" => @password + }) + + access_jwt = account["accessJwt"] + did = account["did"] + + assert %{status: 200, body: app_password} = + AtprotoSdkClient.create_app_password(client, access_jwt, %{ + "name" => "local-sdk", + "scope" => "atproto" + }) + + app_secret = app_password["password"] + + assert %{status: 200, body: app_write} = + AtprotoSdkClient.create_record(client, app_secret, %{ + "repo" => did, + "collection" => "app.tempest.note", + "rkey" => "app-password", + "validate" => false, + "record" => %{"$type" => "app.tempest.note", "text" => "app password write"} + }) + + assert app_write["uri"] == "at://#{did}/app.tempest.note/app-password" + + oauth_access = issue_oauth_access_token!(client, handle) + + dpop = dpop("POST", "http://localhost:4000/xrpc/com.atproto.repo.createRecord", Dpop.issue_nonce()) + + assert %{status: 200, body: oauth_write} = + AtprotoSdkClient.create_record( + client, + oauth_access, + %{ + "repo" => did, + "collection" => "app.tempest.note", + "rkey" => "oauth", + "validate" => false, + "record" => %{"$type" => "app.tempest.note", "text" => "oauth write"} + }, + [{"dpop", dpop}] + ) + + assert oauth_write["uri"] == "at://#{did}/app.tempest.note/oauth" + + assert %{status: 401, body: %{"error" => "InvalidToken"}} = + AtprotoSdkClient.create_record(client, oauth_access, %{ + "repo" => did, + "collection" => "app.tempest.note", + "rkey" => "oauth-missing-dpop", + "validate" => false, + "record" => %{"$type" => "app.tempest.note", "text" => "missing dpop"} + }) + + assert %{status: 200, body: ""} = + AtprotoSdkClient.oauth_revoke(client, %{"token" => oauth_access, "client_id" => @client_id}) + end + + defp issue_oauth_access_token!(client, handle) do + assert %{status: 200, body: %{"request_uri" => request_uri}} = + AtprotoSdkClient.oauth_par( + client, + %{ + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "scope" => "atproto", + "response_type" => "code", + "code_challenge" => code_challenge("verifier"), + "code_challenge_method" => "S256" + }, + dpop("POST", @base_url <> "/oauth/par", Dpop.issue_nonce()) + ) + + authorize_response = + AtprotoSdkClient.oauth_authorize(client, %{ + "request_uri" => request_uri, + "identifier" => handle, + "password" => @password + }) + + assert authorize_response.status == 302 + + code = + authorize_response.headers + |> get_header("location") + |> URI.parse() + |> Map.fetch!(:query) + |> URI.decode_query() + |> Map.fetch!("code") + + assert %{status: 200, body: %{"access_token" => access_token, "token_type" => "DPoP", "scope" => "atproto"}} = + AtprotoSdkClient.oauth_token( + client, + %{ + "grant_type" => "authorization_code", + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "code" => code, + "code_verifier" => "verifier" + }, + dpop("POST", @base_url <> "/oauth/token", Dpop.issue_nonce()) + ) + + access_token + end + + defp websocket_backfill_frames(path, max_frames) do + key = :crypto.strong_rand_bytes(16) |> Base.encode64() + + request = [ + "GET #{path} HTTP/1.1\r\n", + "Host: localhost:4002\r\n", + "Upgrade: websocket\r\n", + "Connection: Upgrade\r\n", + "Sec-WebSocket-Key: #{key}\r\n", + "Sec-WebSocket-Version: 13\r\n", + "\r\n" + ] + + {:ok, socket} = :gen_tcp.connect(~c"localhost", 4002, [:binary, active: false], 1_000) + :ok = :gen_tcp.send(socket, request) + {:ok, response, rest} = recv_until(socket, "\r\n\r\n", "") + assert response =~ " 101 " + + {frames, _rest} = read_ws_frames(socket, rest, max_frames, []) + :gen_tcp.close(socket) + frames + end + + defp recv_until(socket, delimiter, acc) do + case :binary.split(acc, delimiter) do + [headers, rest] -> + {:ok, headers <> delimiter, rest} + + [_incomplete] -> + case :gen_tcp.recv(socket, 0, 2_000) do + {:ok, chunk} -> recv_until(socket, delimiter, acc <> chunk) + other -> other + end + end + end + + defp read_ws_frames(_socket, buffer, 0, frames), do: {Enum.reverse(frames), buffer} + + defp read_ws_frames(socket, buffer, remaining, frames) do + case read_ws_frame(socket, buffer) do + {:ok, payload, rest} -> read_ws_frames(socket, rest, remaining - 1, [payload | frames]) + :timeout -> {Enum.reverse(frames), buffer} + end + end + + defp read_ws_frame(socket, buffer) do + with {:ok, <<0x82, length_code, rest::binary>>} <- ensure_bytes(socket, buffer, 2), + {:ok, length, rest} <- ws_payload_length(socket, rest, length_code &&& 0x7F), + {:ok, payload_and_rest} <- ensure_bytes(socket, rest, length) do + <> = payload_and_rest + {:ok, payload, remaining} + else + :timeout -> :timeout + end + end + + defp ensure_bytes(_socket, buffer, size) when byte_size(buffer) >= size, do: {:ok, buffer} + + defp ensure_bytes(socket, buffer, size) do + case :gen_tcp.recv(socket, 0, 2_000) do + {:ok, chunk} -> ensure_bytes(socket, buffer <> chunk, size) + {:error, :timeout} -> :timeout + {:error, reason} -> {:error, reason} + end + end + + defp ws_payload_length(_socket, rest, length) when length < 126, do: {:ok, length, rest} + + defp ws_payload_length(socket, rest, 126) do + with {:ok, <>} <- ensure_bytes(socket, rest, 2) do + {:ok, length, remaining} + end + end + + defp ws_payload_length(socket, rest, 127) do + with {:ok, <>} <- ensure_bytes(socket, rest, 8) do + {:ok, length, remaining} + end + end + + defp commit_frame?(frame, did) do + header = Drisl.encode!(%{"op" => 1, "t" => "#commit"}) + + if String.starts_with?(frame, header) do + payload = binary_part(frame, byte_size(header), byte_size(frame) - byte_size(header)) + + case Drisl.decode(payload) do + {:ok, %{"repo" => ^did}} -> true + _other -> false + end + else + false + end + end + + defp dpop(method, url, nonce) do + header = %{ + "typ" => "dpop+jwt", + "alg" => "ES256", + "jwk" => %{"kty" => "EC", "crv" => "P-256", "x" => "x", "y" => "y"} + } + + payload = %{ + "htu" => url, + "htm" => method, + "iat" => DateTime.utc_now() |> DateTime.to_unix(), + "jti" => Ecto.UUID.generate(), + "nonce" => nonce + } + + [header, payload, "signature"] + |> Enum.map(&encode_part/1) + |> Enum.join(".") + end + + defp encode_part(value) when is_map(value), do: value |> Jason.encode!() |> encode_part() + defp encode_part(value), do: Base.url_encode64(value, padding: false) + + defp code_challenge(verifier), do: :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + + defp get_header(headers, name) do + headers + |> Map.fetch!(String.downcase(name)) + |> List.first() + end +end diff --git a/test/tempest_web/xrpc/compatibility_auth_content_test.exs b/test/tempest_web/xrpc/compatibility_auth_content_test.exs new file mode 100644 index 0000000..4b7a9e4 --- /dev/null +++ b/test/tempest_web/xrpc/compatibility_auth_content_test.exs @@ -0,0 +1,349 @@ +defmodule TempestWeb.Xrpc.CompatibilityAuthContentTest do + use TempestWeb.ConnCase, async: false + + alias Tempest.{Accounts, AdminAuth} + alias Tempest.OAuth.Dpop + + @password "correct horse battery staple" + @client_id "did:web:compat-client.example.com" + @redirect_uri "https://compat-client.example.com/cb" + + setup do + old_hash = Application.get_env(:tempest, :admin_token_hash) + + on_exit(fn -> + if old_hash do + Application.put_env(:tempest, :admin_token_hash, old_hash) + else + Application.delete_env(:tempest, :admin_token_hash) + end + end) + + :ok + end + + test "ConnCase auth matrix covers bearer, app password, OAuth, admin, and missing credentials", %{conn: conn} do + account = create_account!("auth-matrix.test", "auth-matrix@example.com") + access_jwt = account["accessJwt"] + did = account["did"] + + assert %{"did" => ^did, "handle" => "auth-matrix.test"} = + conn + |> bearer(access_jwt) + |> get(~p"/xrpc/com.atproto.server.getSession") + |> json_response(200) + + assert_error( + get(conn, ~p"/xrpc/com.atproto.server.getSession"), + 401, + "AuthenticationRequired", + "Bearer token is required" + ) + + app_password = create_app_password!(conn, access_jwt) + + assert %{"uri" => "at://" <> _, "commit" => %{"cid" => _}} = + conn + |> bearer_json(app_password) + |> post(~p"/xrpc/com.atproto.repo.createRecord", %{ + "repo" => account["did"], + "collection" => "app.tempest.note", + "rkey" => "app-password", + "validate" => false, + "record" => %{"$type" => "app.tempest.note", "text" => "app password write"} + }) + |> json_response(200) + + assert_error( + conn + |> bearer(app_password) + |> get(~p"/xrpc/com.atproto.server.getSession"), + 403, + "AuthScopeInsufficient", + "Bearer token scope is insufficient" + ) + + oauth_access = issue_oauth_access_token!(conn, account) + dpop = dpop("POST", "http://localhost:4000/xrpc/com.atproto.repo.createRecord", Dpop.issue_nonce()) + + assert %{"uri" => "at://" <> _, "commit" => %{"cid" => _}} = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{oauth_access}") + |> put_req_header("dpop", dpop) + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.repo.createRecord", %{ + "repo" => account["did"], + "collection" => "app.tempest.note", + "rkey" => "oauth", + "validate" => false, + "record" => %{"$type" => "app.tempest.note", "text" => "oauth write"} + }) + |> json_response(200) + + assert_error( + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{oauth_access}") + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.repo.createRecord", %{ + "repo" => account["did"], + "collection" => "app.tempest.note", + "rkey" => "oauth-missing-dpop", + "validate" => false, + "record" => %{"$type" => "app.tempest.note", "text" => "missing dpop"} + }), + 401, + "InvalidToken", + "DPoP proof is required" + ) + + Application.put_env(:tempest, :admin_token_hash, AdminAuth.hash_token("admin-secret-token")) + + assert_error( + conn + |> recycle() + |> put_req_header("authorization", "Bearer admin-secret-token") + |> get(~p"/xrpc/com.atproto.server.getSession"), + 401, + "InvalidToken", + "Bearer token is invalid" + ) + + assert_error( + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{access_jwt}") + |> get(~p"/xrpc/_admin/status"), + 401, + "InvalidToken", + "Admin bearer token is invalid" + ) + end + + test "XRPC verb checks reject GET procedures and POST queries before handlers", %{conn: conn} do + account = create_account!("verb-matrix.test", "verb-matrix@example.com") + + assert_error( + post_json(conn, ~p"/xrpc/com.atproto.server.describeServer", %{}), + 400, + "InvalidRequest", + "com.atproto.server.describeServer is a query method and must use GET, not POST" + ) + + assert_error( + get(conn, ~p"/xrpc/com.atproto.server.createSession"), + 400, + "InvalidRequest", + "com.atproto.server.createSession is a procedure method and must use POST, not GET" + ) + + assert_error( + conn + |> bearer(account["accessJwt"]) + |> post(~p"/xrpc/com.atproto.server.getSession", %{}), + 400, + "InvalidRequest", + "com.atproto.server.getSession is a query method and must use GET, not POST" + ) + + assert_error( + conn + |> bearer(account["accessJwt"]) + |> get(~p"/xrpc/com.atproto.repo.createRecord"), + 400, + "InvalidRequest", + "com.atproto.repo.createRecord is a procedure method and must use POST, not GET" + ) + + assert_error_contains( + conn + |> recycle() + |> get(~p"/xrpc/com.atproto.sync.subscribeRepos"), + 426, + "UpgradeRequired", + "host" + ) + end + + test "XRPC content-type checks cover JSON, blob, CAR, and empty-body procedures", %{conn: conn} do + account = create_account!("content-type-matrix.test", "content-type-matrix@example.com") + access_jwt = account["accessJwt"] + + assert_error( + conn + |> recycle() + |> put_req_header("content-type", "text/plain") + |> post(~p"/xrpc/com.atproto.server.createSession", "not json"), + 400, + "InvalidRequest", + "request body must use content-type application/json" + ) + + assert_error( + raw_post_no_content_type(~p"/xrpc/com.atproto.repo.uploadBlob", access_jwt, "abc"), + 400, + "InvalidRequest", + "request body must include a content-type" + ) + + assert_error( + conn + |> bearer(access_jwt) + |> put_req_header("content-type", "text/plain") + |> post(~p"/xrpc/com.atproto.repo.importRepo", "not a car"), + 400, + "InvalidRequest", + "request body must use content-type application/vnd.ipld.car" + ) + + assert_error( + conn + |> bearer(access_jwt) + |> put_req_header("content-type", "application/vnd.ipld.car") + |> post(~p"/xrpc/com.atproto.repo.importRepo", "not a car"), + 400, + "InvalidRequest", + "import CAR is invalid" + ) + + assert %{"handle" => "content-type-matrix.test"} = + conn + |> bearer(account["refreshJwt"]) + |> post(~p"/xrpc/com.atproto.server.refreshSession") + |> json_response(200) + end + + defp create_account!(handle, email) do + {:ok, account} = + Accounts.create_account(%{ + "handle" => handle, + "email" => email, + "password" => @password + }) + + account + end + + defp create_app_password!(conn, access_jwt) do + conn + |> bearer_json(access_jwt) + |> post(~p"/xrpc/com.atproto.server.createAppPassword", %{"name" => "compat", "scope" => "atproto"}) + |> json_response(200) + |> Map.fetch!("password") + end + + defp issue_oauth_access_token!(conn, account) do + par_conn = + conn + |> recycle() + |> put_req_header("dpop", dpop("POST", "http://localhost:4002/oauth/par", Dpop.issue_nonce())) + |> post(~p"/oauth/par", %{ + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "scope" => "atproto", + "response_type" => "code", + "code_challenge" => code_challenge("verifier"), + "code_challenge_method" => "S256" + }) + + %{"request_uri" => request_uri} = json_response(par_conn, 200) + + authorize_conn = + conn + |> recycle() + |> post(~p"/oauth/authorize", %{ + "request_uri" => request_uri, + "identifier" => account["handle"], + "password" => @password + }) + + [location] = get_resp_header(authorize_conn, "location") + code = location |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() |> Map.fetch!("code") + + token_conn = + conn + |> recycle() + |> put_req_header("dpop", dpop("POST", "http://localhost:4002/oauth/token", Dpop.issue_nonce())) + |> post(~p"/oauth/token", %{ + "grant_type" => "authorization_code", + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "code" => code, + "code_verifier" => "verifier" + }) + + token_conn + |> json_response(200) + |> Map.fetch!("access_token") + end + + defp bearer(conn, token) do + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{token}") + end + + defp bearer_json(conn, token) do + conn + |> bearer(token) + |> put_req_header("content-type", "application/json") + end + + defp raw_post_no_content_type(path, token, body) do + conn = + Plug.Test.conn("POST", path, body) + |> put_req_header("authorization", "Bearer #{token}") + + TempestWeb.Endpoint.call(conn, []) + end + + defp post_json(conn, path, params) do + conn + |> recycle() + |> put_req_header("content-type", "application/json") + |> post(path, params) + end + + defp dpop(method, url, nonce) do + header = %{ + "typ" => "dpop+jwt", + "alg" => "ES256", + "jwk" => %{"kty" => "EC", "crv" => "P-256", "x" => "x", "y" => "y"} + } + + payload = %{ + "htu" => url, + "htm" => method, + "iat" => DateTime.utc_now() |> DateTime.to_unix(), + "jti" => Ecto.UUID.generate(), + "nonce" => nonce + } + + [header, payload, "signature"] + |> Enum.map(&encode_part/1) + |> Enum.join(".") + end + + defp encode_part(value) when is_map(value), do: value |> Jason.encode!() |> encode_part() + defp encode_part(value), do: Base.url_encode64(value, padding: false) + + defp code_challenge(verifier) do + :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + end + + defp assert_error(conn, status, error, message) do + response = json_response(conn, status) + + assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] + assert response == %{"error" => error, "message" => message} + end + + defp assert_error_contains(conn, status, error, message_part) do + response = json_response(conn, status) + + assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] + assert response["error"] == error + assert response["message"] =~ message_part + end +end -- 2.51.2