CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: update project context for test-oauth-client branch

Refresh the CLAUDE.md tree to reflect the new `test oauth client`
subcommand introduced across Phases 1-8:

- Root CLAUDE.md: add the new subcommand's CLI shape, the jsonwebtoken /
axum / rand_chacha / rand_core / serde_urlencoded dependencies, the
src/commands/test/oauth/client/ tree, the src/common/oauth/ submodule,
the promoted src/common/report.rs module, and the new oauth_client_*
integration binaries. Note the use of tokio `signal` (for fake AS
ctrl-c wait) and axum 0.8.
- src/common/CLAUDE.md: broaden scope from identity/diagnostics only to
also cover the new oauth submodule (Clock, JWS helpers, RelyingParty +
RpFactory variants) and the promoted report contract (CheckStatus,
Stage newtype, exit-code rules, blocked_by helpers). Refresh
dependency list and boundary statement.
- src/commands/test/labeler/CLAUDE.md: fix stale references now that
report.rs has moved to common::report. Bump freshness.
- src/commands/test/oauth/client/CLAUDE.md: correct the injection-seams
section — the CLI path uses WaitForExternalClient and never builds an
RpFactory; only DriveRpInProcess (tests) does. Expand interactive-mode
description to cover both drive modes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

+183 -45
+51 -17
CLAUDE.md
··· 1 1 # atproto-devtool 2 2 3 - Last verified: 2026-04-17 3 + Last verified: 2026-04-21 4 4 5 5 Single-crate Rust 2024 binary providing developer tooling for the atproto 6 6 ecosystem. Shipped subcommands: `test labeler` (conformance suite against an 7 - atproto labeler) and `test oauth client` (scaffolding for OAuth client 8 - conformance tests). 7 + atproto labeler) and `test oauth client` (conformance suite for an atproto 8 + OAuth client — static checks plus an optional interactive mode that runs an 9 + in-process fake authorization server). 9 10 10 11 ## Tech stack 11 12 12 13 - Rust 2024, single crate (binary + library). Library target exists so 13 14 integration tests under `tests/*.rs` can reach pipeline internals via 14 - `atproto_devtool::commands::test::labeler::...`. 15 - - Async runtime: tokio (rt + macros + time + net only — not full). 15 + `atproto_devtool::commands::test::labeler::...` and 16 + `atproto_devtool::commands::test::oauth::client::...`. 17 + - Async runtime: tokio (rt + macros + time + net + signal — not full). 16 18 - HTTP: reqwest with rustls. WebSockets: tokio-tungstenite with 17 - rustls-tls-native-roots. 19 + rustls-tls-native-roots. In-process HTTP server (fake AS for the oauth 20 + client interactive mode): axum 0.8 (default features off; `http1` + `json` 21 + + `tokio` only). 18 22 - CLI: clap derive. Diagnostics: miette (fancy) + thiserror. 19 - - Crypto: k256 + p256 (ECDSA), sha2, multibase. 23 + - Crypto: k256 + p256 (ECDSA), sha2, multibase. JWS/JWK signing and parsing 24 + via `jsonwebtoken` 10 with the `rust_crypto` backend (no ring). PKCS8 25 + export on p256 is required because `jsonwebtoken`'s rust_crypto backend 26 + loads keys through `from_pkcs8_der`. 27 + - Deterministic RNG for OAuth test flows: rand_chacha + rand_core (seeded 28 + `ChaCha20Rng`). Form encoding for OAuth request bodies: serde_urlencoded. 20 29 - atproto types: atrium-api (not atrium-xrpc-client — we use reqwest directly 21 30 through our own seams so every network hop is mockable). 22 31 - Testing: insta snapshots, assert_cmd for CLI end-to-end. 23 32 24 33 ## Commands 25 34 26 - - `cargo build` / `cargo run -- test labeler <target>` / `cargo run -- test oauth client <target> [interactive]` 35 + - `cargo build` / `cargo run -- test labeler <target>` / 36 + `cargo run -- test oauth client <target>` (static checks) / 37 + `cargo run -- test oauth client <target> interactive [--port N] [--public-base-url URL]` 38 + (static checks plus in-process fake AS driving a full OAuth flow). 27 39 - `cargo test` — unit + integration + snapshot tests. 28 40 - `cargo insta review` — review snapshot diffs after test runs. 29 41 - `cargo fmt` / `cargo clippy -- -D warnings` — both must be clean. ··· 39 51 `DEBUG`), `NO_COLOR`/`--no-color` handling. 40 52 - `src/commands/test/labeler/` — the `test labeler` subcommand. See 41 53 `src/commands/test/labeler/CLAUDE.md` for pipeline architecture. 42 - - `src/commands/test/oauth/` — the `test oauth client` subcommand tree (static 43 - and interactive modes). Scaffolding phase (Phase 1 of 8). See the 44 - implementation plans for phase details. 54 + - `src/commands/test/oauth/client/` — the `test oauth client` subcommand 55 + (discovery / metadata / JWKS static stages plus an optional interactive 56 + stage with `scope_variations` and `dpop_edges` sub-stages). Contains an 57 + in-process `fake_as` axum server module used in interactive mode. See 58 + `src/commands/test/oauth/client/CLAUDE.md`. 45 59 - `src/common/identity.rs` — DID/handle/multikey/PLC primitives shared across 46 60 stages. See `src/common/CLAUDE.md`. 47 - - `src/common/report.rs` — shared report contract (`CheckStatus`, `CheckResult`, 48 - `Stage`, `LabelerReport`). Used by both labeler and oauth_client commands. 61 + - `src/common/oauth/` — OAuth 2.0 / JOSE primitives shared by the `test oauth 62 + *` family: `clock` (testable time source), `jws` (JWK parsing + ES256 63 + signing via `jsonwebtoken`), `relying_party` (atproto-spec RP that drives 64 + PAR, PKCE S256, DPoP, private_key_jwt). See `src/common/CLAUDE.md`. 65 + - `src/common/report.rs` — shared report contract (`CheckStatus`, 66 + `CheckResult`, `Stage`, `LabelerReport`, `RenderConfig`, plus the 67 + `blocked_by` / `skipped_with_reason` helpers). Promoted out of the labeler 68 + tree in Phase 1 of the oauth client work; used by both labeler and 69 + oauth_client commands. `Stage` is a `&'static str` newtype so commands can 70 + extend the stage space without a shared enum. 49 71 - `src/common/diagnostics.rs` — small helpers for building `NamedSource` and 50 72 spans against JSON payloads. 51 - - `tests/` — one integration binary per stage (`labeler_identity.rs`, 52 - `labeler_http.rs`, `labeler_subscription.rs`, `labeler_endtoend.rs`, 53 - `labeler_cli.rs`) plus shared fakes in `tests/common/mod.rs`. 73 + - `tests/` — one integration binary per stage. Labeler: 74 + `labeler_identity.rs`, `labeler_http.rs`, `labeler_subscription.rs`, 75 + `labeler_endtoend.rs`, `labeler_cli.rs`. OAuth client: 76 + `oauth_client_discovery.rs`, `oauth_client_metadata.rs`, 77 + `oauth_client_jwks.rs`, `oauth_client_interactive.rs`, 78 + `oauth_client_endtoend.rs`, `oauth_client_cli.rs`, 79 + `oauth_client_broken_rp.rs` (interactive-mode failure ACs driven by a 80 + deliberately broken RP), `oauth_client_check_id_coverage.rs` (guards that 81 + every check ID and diagnostic code is pinned in a snapshot). Shared fakes 82 + (`FakeHttpClient`, `FakeDnsResolver`, `FakeRawHttpTee`, 83 + `FakeWebSocketClient`, `FakeJwksFetcher`, `FakeClock`) live in 84 + `tests/common/mod.rs`. 54 85 - `tests/fixtures/` — JSON + binary CBOR fixtures per stage. The 55 86 `tests/snapshots/` tree holds insta snapshots. 56 87 - `docs/implementation-plans/2026-04-13-test-labeler/` — the phased 57 88 implementation plan that produced the labeler subcommand. 58 89 - `docs/implementation-plans/2026-04-16-test-oauth-client/` — the phased 59 - implementation plan for the oauth client subcommand (Phases 1-8). 90 + implementation plan (Phases 1-8) that produced the oauth client 91 + subcommand. 92 + - `docs/test-oauth-client-reachability.md` — operator-facing guide for 93 + exposing the fake AS over HTTPS (tunnels / `--public-base-url`). 60 94 61 95 ## Conventions 62 96
+6 -5
src/commands/test/labeler/CLAUDE.md
··· 1 1 # test labeler 2 2 3 - Last verified: 2026-04-15 3 + Last verified: 2026-04-21 4 4 5 5 ## Purpose 6 6 ··· 27 27 `subscription::run`, `crypto::run`. Each returns a `*StageOutput` with an 28 28 `Option<*Facts>` (populated only when the stage succeeds enough to let 29 29 downstream stages run) plus a `Vec<CheckResult>`. 30 - - **Report shape**: `report::{LabelerReport, CheckResult, CheckStatus, 31 - Stage, SummaryCounts, ReportHeader, RenderConfig}`. Five-way 30 + - **Report shape**: `crate::common::report::{LabelerReport, CheckResult, 31 + CheckStatus, Stage, SummaryCounts, ReportHeader, RenderConfig}` (the 32 + report module was promoted out of this tree in the oauth client work; the 33 + type is still named `LabelerReport` for historical reasons and is reused 34 + unchanged by oauth_client). Five-way 32 35 `CheckStatus`: `Pass`, `SpecViolation`, `NetworkError`, `Advisory`, 33 36 `Skipped`. Exit code semantics: `1` if any `SpecViolation` is 34 37 recorded; else `2` if any `NetworkError` is recorded; else `0`. ··· 119 122 - `labeler.rs` — clap args, `LabelerCmd::run`, CLI bootstrap. 120 123 - `pipeline.rs` — `LabelerTarget`, `LabelerOptions`, `parse_target`, 121 124 `run_pipeline` orchestration. 122 - - `report.rs` — `CheckStatus`, `CheckResult`, `LabelerReport`, 123 - `RenderConfig`, rendering via `miette::GraphicalReportHandler`. 124 125 - `identity.rs` — identity stage: DID resolution, labeler record fetch 125 126 (through `atrium-api` types over the `HttpClient` seam), policy 126 127 validation.
+28 -9
src/commands/test/oauth/client/CLAUDE.md
··· 43 43 44 44 Every network hop is a trait object, allowing full testability and determinism: 45 45 46 - - `HttpClient` — Metadata and JWKS discovery. 47 - - `JwksFetcher` — JWKS URI resolution. 48 - - `Clock` — Deterministic timestamp generation. 49 - - `RpFactory` — Builds RelyingParty instances with seeded crypto. 46 + - `HttpClient` (from `common::identity`) — metadata and JWKS discovery. 47 + - `JwksFetcher` (defined in `pipeline::jwks`) — JWKS URI resolution; separated from 48 + `HttpClient` so the JWKS stage can mint distinct `NetworkError` diagnostics. 49 + - `Clock` (from `common::oauth::clock`) — timestamp source for the RP and 50 + for any check that reads the current time. 51 + - `RpFactory` (from `common::oauth::relying_party`) — only consulted when 52 + `InteractiveDriveMode::DriveRpInProcess` is set. The CLI path uses 53 + `WaitForExternalClient` and never builds an RP. 50 54 51 - Production wires `RealClock` and `DefaultRpFactory`; tests wire `FakeClock` and `DeterministicRpFactory`. 55 + Production wires `RealClock` and `RealHttpClient`; the CLI does not wire an 56 + `RpFactory` (it waits for an external client). Tests wire `FakeClock`, 57 + `FakeHttpClient`, `FakeJwksFetcher`, and — for interactive-mode tests — 58 + `DeterministicRpFactory`. 52 59 53 60 ## Interactive Mode (Fake AS) 54 61 55 - The interactive stage instantiates a real axum HTTP server (the fake AS) and a RelyingParty (the test probe): 62 + The interactive stage instantiates a real axum HTTP server (the fake AS) 63 + exposing OAuth discovery, metadata, PAR, authorize, token, and refresh 64 + endpoints. Two drive modes: 56 65 57 - - **Fake AS** — Echoes OAuth discovery, serves metadata, handles PAR/authorize/token endpoints, enforces DPoP, rotation, and nonce tracking. 58 - - **RelyingParty** — Deterministic ECDSA signing, PKCE, and state management; driven by `FlowScript` enum per test case. 66 + - **`WaitForExternalClient`** (CLI default) — the fake AS binds, prints 67 + instructions, and waits for `ctrl-c`; an operator drives their real client 68 + against the printed URL and the check logic inspects `RequestLog` 69 + afterwards. Set `--public-base-url` to advertise a tunnel URL. 70 + - **`DriveRpInProcess { rp_factory }`** (tests) — the pipeline also 71 + constructs a `RelyingParty` via the injected factory and drives a full 72 + flow in-process. Request/response recording lives in 73 + `fake_as::RequestLog`; per-flow scripting (approve/deny/nonce injection/ 74 + rotation) is in `fake_as::endpoints`. 59 75 60 - The `FlowScript` struct drives the fake AS's per-flow response behavior (approve, deny, rotation, nonce injection, etc.). Both components are deterministic under a fixed `Clock` and RNG seed. 76 + Both the RP and the fake AS are deterministic under a fixed `Clock` and RNG 77 + seed: identical runs produce identical DPoP proofs, PKCE verifiers, and 78 + request signatures, which is what makes snapshot-based assertions viable 79 + for the interactive path. 61 80 62 81 ## Invariants 63 82
+98 -14
src/common/CLAUDE.md
··· 1 1 # common 2 2 3 - Last verified: 2026-04-15 3 + Last verified: 2026-04-21 4 4 5 5 ## Purpose 6 6 7 - Narrow, mockable primitives shared by every labeler conformance stage. 8 - `common::identity` exists so that every network hop — DNS, HTTPS, PLC 9 - directory lookups, DID document fetches, labeler record fetches — can be 10 - swapped with a recorded fixture in integration tests. `common::diagnostics` 11 - holds the miette `NamedSource`/`SourceSpan` helpers used when attaching JSON 12 - source context to check failures. 7 + Narrow, mockable primitives shared across `atproto-devtool`'s conformance 8 + test subcommands. Four submodules: 9 + 10 + - `common::identity` — DNS, HTTPS, PLC directory, DID document, and labeler 11 + record primitives. Every network hop here is a trait object so integration 12 + tests can replay recorded fixtures instead of hitting real servers. 13 + - `common::oauth` — OAuth 2.0 / JOSE primitives for the `test oauth *` 14 + family: `clock` (trait-object time source), `jws` (JWK parsing + ES256 15 + signing/verification via `jsonwebtoken`), and `relying_party` (atproto-spec 16 + Relying Party that drives PAR, PKCE S256, DPoP proofs, and 17 + `private_key_jwt` with deterministic seeded RNG for reproducible tests). 18 + - `common::report` — the cross-subcommand report contract (`CheckStatus`, 19 + `CheckResult`, `Stage`, `LabelerReport`, `RenderConfig`, plus the 20 + `blocked_by` / `skipped_with_reason` skip-row constructors). Promoted out 21 + of the labeler tree so oauth_client can reuse it. Both subcommands render 22 + through `LabelerReport::render`; the name is historical. 23 + - `common::diagnostics` — miette `NamedSource` / `SourceSpan` helpers used 24 + when attaching JSON source context to check failures. 13 25 14 26 ## Contracts 15 27 ··· 28 40 - `IdentityError` — single error enum covering every resolution failure. 29 41 Variants are matched on by the identity stage to emit distinct check 30 42 results, so adding or removing variants is a contract change. 43 + - **Exposes from `oauth::clock`**: 44 + - Trait: `Clock` (`now_unix_seconds(&self) -> u64`). The only sanctioned 45 + time source for anything the OAuth tests observe. Passed as 46 + `Arc<dyn Clock>` so both the pipeline and the in-process RP can share 47 + one instance. 48 + - Real implementation: `RealClock`. Tests inject `FakeClock` from 49 + `tests/common/mod.rs`. 50 + - **Exposes from `oauth::jws`**: 51 + - Enums: `JwsAlg` (`Es256` | `Es256k`), `JwkUse` (`Sig` | `Other(Arc<str>)`). 52 + - Types: `ParsedJwk` (kid, alg, alg_raw, r#use, pre-built verifier for 53 + ES256 only), plus the error type `JwsError` (`#[derive(Diagnostic)]` 54 + with `oauth_client::jws::*` codes). 55 + - Functions: `parse_jwk`, `sign_jws`, `verify_jws`. 56 + - `sign_jws`/`verify_jws` only support ES256. ES256K parses but signing 57 + and verification return `JwsError::UnsupportedOperation` — handled at 58 + the JWKS stage as a curve-not-modern-EC violation rather than a hard 59 + structural failure. 60 + - **Exposes from `oauth::relying_party`**: 61 + - Enum: `ClientKind` (`Confidential` | `Public`). 62 + - Types: `RelyingParty`, `AsDescriptor`, `ParRequest`, `ParResponse`, 63 + `TokenResponse`, `AuthorizeOutcome`, `RpError` 64 + (`#[derive(Diagnostic)]` with `oauth_client::relying_party::*` codes). 65 + - Trait: `RpFactory` (`fn build(client_id: Url, kind: ClientKind) -> 66 + Arc<RelyingParty>`). Two implementations: `DefaultRpFactory` 67 + (production; fresh OS entropy per call) and `DeterministicRpFactory` 68 + (tests; seeded `ChaCha20Rng` for reproducible DPoP proofs and PKCE 69 + verifiers). 70 + - DPoP nonce rotation is handled inside `RelyingParty` — callers do not 71 + plumb nonces through. 72 + - **Exposes from `report`**: 73 + - Enum: `CheckStatus` (`Pass` | `SpecViolation` | `NetworkError` | 74 + `Advisory` | `Skipped`). Rendering glyphs (`[OK]`/`[FAIL]`/`[NET]`/ 75 + `[WARN]`/`[SKIP]`) and per-status ANSI colors are part of the public 76 + contract — snapshot tests pin them. 77 + - Struct: `CheckResult { id: &'static str, stage: Stage, status, summary, 78 + diagnostic: Option<Box<dyn Diagnostic + Send + Sync>>, skipped_reason }`. 79 + - Newtype: `Stage(pub &'static str)` with `label()` and the constants 80 + `IDENTITY`, `HTTP`, `SUBSCRIPTION`, `CRYPTO` (labeler) plus 81 + `DISCOVERY`, `METADATA`, `JWKS`, `INTERACTIVE` (oauth client). Use 82 + `Stage::<NAME>` rather than constructing ad-hoc stages — the render 83 + loop groups by stage identity and stable heading strings appear in 84 + snapshots. 85 + - Struct: `LabelerReport { header, results, started_at, finished_at }` 86 + with `record`, `finish`, `exit_code`, `summary_counts`, and 87 + `render(&mut W, &RenderConfig)`. The name is historical; the OAuth 88 + client subcommand wraps it in `OauthClientReport` rather than 89 + duplicating the rendering logic. 90 + - Helpers: `blocked_by(check_id, stage, summary, blocker_check_id)` 91 + produces `Skipped` with `reason = "blocked by <blocker_check_id>"`. 92 + `skipped_with_reason` produces `Skipped` with an arbitrary reason for 93 + non-blocking skips (e.g. "metadata is implicit for loopback clients"). 94 + - Exit code contract: `1` if any `SpecViolation`, else `2` if any 95 + `NetworkError`, else `0`. `Advisory` and `Skipped` never fail a run. 96 + `SpecViolation` takes precedence over `NetworkError`. 31 97 - **Guarantees**: 32 98 - `resolve_did` returns `RawDidDocument` with the original bytes retained 33 99 in an `Arc<[u8]>` so downstream stages can build `NamedSource` ··· 50 116 ## Dependencies 51 117 52 118 - **Uses**: `reqwest` (rustls, json, gzip), `hickory-resolver`, `k256`, 53 - `p256`, `multibase`, `sha2`, `serde_json`, `miette`, `thiserror`. 54 - - **Used by**: every stage in `commands/test/labeler/`, plus integration 55 - tests under `tests/`. 56 - - **Boundary**: `common::identity` must not depend on anything under 57 - `commands/`. Stage-specific types (`IdentityFacts`, `HttpFacts`, etc.) 58 - live next to their stage, not here. 119 + `p256` (with `pkcs8` feature for the RP), `multibase`, `sha2`, 120 + `serde_json`, `serde_urlencoded` (OAuth form bodies), `miette`, 121 + `thiserror`, `jsonwebtoken` (rust_crypto backend, for `oauth::jws`), 122 + `rand_chacha` + `rand_core` (for the deterministic RP RNG), `url`. 123 + - **Used by**: every stage in `commands/test/labeler/` and 124 + `commands/test/oauth/client/`, plus integration tests under `tests/`. 125 + - **Boundary**: nothing in `common/` depends on anything under `commands/`. 126 + Stage-specific types (`IdentityFacts`, `DiscoveryFacts`, `MetadataFacts`, 127 + etc.) live next to their stage, not here. `common::report` is the only 128 + cross-subcommand coupling — both labeler and oauth_client share its 129 + `CheckStatus` / `Stage` / `LabelerReport` contracts, and changes to those 130 + ripple through every snapshot in the crate. 59 131 60 132 ## Key decisions 61 133 ··· 86 158 87 159 ## Key files 88 160 89 - - `identity.rs` — all of the above, plus extensive unit tests at the bottom. 161 + - `identity.rs` — all `HttpClient` / `DnsResolver` / DID plumbing, plus 162 + extensive unit tests at the bottom. 163 + - `oauth/clock.rs` — `Clock` trait and `RealClock`. 164 + - `oauth/jws.rs` — `JwsAlg`, `JwkUse`, `ParsedJwk`, `JwsError`, `parse_jwk`, 165 + `sign_jws`, `verify_jws`. All `JwsError` variants carry stable 166 + `oauth_client::jws::*` diagnostic codes pinned in snapshots. 167 + - `oauth/relying_party.rs` — `RelyingParty`, `RpFactory`, 168 + `DefaultRpFactory`, `DeterministicRpFactory`, and the request/response 169 + types for PAR / authorize / token / refresh. Signing keys are generated 170 + from a seeded `ChaCha20Rng` when built through `DeterministicRpFactory`. 171 + - `report.rs` — `CheckStatus`, `CheckResult`, `Stage`, `LabelerReport`, 172 + `RenderConfig`, `blocked_by`, `skipped_with_reason`. Unit tests at the 173 + bottom pin exit-code and render behavior. 90 174 - `diagnostics.rs` — `install_miette_handler`, `named_source_from_bytes` / 91 175 `named_source_from_str`, plus the JSON display helpers: 92 176 `pretty_json_for_display` (re-serialize a JSON body so miette's caret