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 CLAUDE.md after labeler report stage

Bump freshness dates to 2026-04-19 and reflect contract surface added by
the 55-commit labeler report stage implementation:

- Root CLAUDE.md: add `src/common/jwt.rs`, getrandom direct dep, the
new `labeler_report.rs` and `common_fakes.rs` integration binaries,
and `CreateReportTee` / `PdsXrpcClient` in the seam trait list.
- src/commands/test/labeler/CLAUDE.md: add the fifth stage (Report),
document the `create_report::run` entry point, the 10-check stable
order (`Check::ORDER`), the new seam traits and `Real*`
implementations, per-check diagnostic structs, CLI flags
(`--commit-report`, `--force-self-mint`, `--self-mint-curve`,
`--report-subject-did`, `--handle`, `--app-password`), sentinel +
pollution-avoidance decisions, and new invariants/gotchas around
row count, timing normalization, self-mint locality, and the
single-createSession PDS flow.
- src/common/CLAUDE.md: add `AnySigningKey`, `encode_multikey`,
`is_local_labeler_hostname`, `AnySignature::to_jws_bytes`, and the
full `jwt` module surface. Note the low-s p256 normalization
invariant, the hand-rolled JWT decision, and the IPv6-private
conservative default.

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

authored by

Jack Grigg
Claude Opus 4.7
and committed by
Tangled
a01c9860 e7db5652

+253 -63
+25 -13
CLAUDE.md
··· 1 1 # atproto-devtool 2 2 3 - Last verified: 2026-04-15 3 + Last verified: 2026-04-19 4 4 5 5 Single-crate Rust 2024 binary providing developer tooling for the atproto 6 6 ecosystem. Today the only shipped subcommand is `test labeler`, which runs a ··· 15 15 - HTTP: reqwest with rustls. WebSockets: tokio-tungstenite with 16 16 rustls-tls-native-roots. 17 17 - CLI: clap derive. Diagnostics: miette (fancy) + thiserror. 18 - - Crypto: k256 + p256 (ECDSA), sha2, multibase. 18 + - Crypto: k256 + p256 (ECDSA), sha2, multibase. `getrandom` is a direct 19 + dep so we can reach `OsRng` for JWT `jti` nonces and the sentinel 20 + run-id (the transitive `rand_core` inside `elliptic-curve` is built 21 + without the `getrandom` feature). 22 + - JWTs: hand-rolled compact JWS in `src/common/jwt.rs` (ES256 and ES256K 23 + only). We do NOT depend on a JWT library. 19 24 - atproto types: atrium-api (not atrium-xrpc-client — we use reqwest directly 20 25 through our own seams so every network hop is mockable). 21 26 - Testing: insta snapshots, assert_cmd for CLI end-to-end. ··· 38 43 `DEBUG`), `NO_COLOR`/`--no-color` handling. 39 44 - `src/commands/test/labeler/` — the `test labeler` subcommand. See 40 45 `src/commands/test/labeler/CLAUDE.md` for pipeline architecture. 41 - - `src/common/identity.rs` — DID/handle/multikey/PLC primitives shared across 42 - stages. See `src/common/CLAUDE.md`. 46 + - `src/common/identity.rs` — DID/handle/multikey/PLC primitives plus 47 + signing-key types shared across stages. See `src/common/CLAUDE.md`. 48 + - `src/common/jwt.rs` — compact JWS encoder/decoder (ES256 / ES256K) used 49 + by the report stage to mint self-mint service-auth tokens. See 50 + `src/common/CLAUDE.md`. 43 51 - `src/common/diagnostics.rs` — small helpers for building `NamedSource` and 44 52 spans against JSON payloads. 45 53 - `tests/` — one integration binary per stage (`labeler_identity.rs`, 46 - `labeler_http.rs`, `labeler_subscription.rs`, `labeler_endtoend.rs`, 47 - `labeler_cli.rs`) plus shared fakes in `tests/common/mod.rs`. 54 + `labeler_http.rs`, `labeler_subscription.rs`, `labeler_report.rs`, 55 + `labeler_endtoend.rs`, `labeler_cli.rs`) plus shared fakes in 56 + `tests/common/mod.rs` and fake smoke tests in `tests/common_fakes.rs`. 48 57 - `tests/fixtures/` — JSON + binary CBOR fixtures per stage. The 49 58 `tests/snapshots/` tree holds insta snapshots. 50 - - `docs/implementation-plans/2026-04-13-test-labeler/` — the phased 51 - implementation plan that produced this codebase. Still useful for grepping 52 - rationale on individual decisions. 59 + - `docs/implementation-plans/2026-04-13-test-labeler/` — the initial phased 60 + implementation plan for the four-stage pipeline. The report stage was 61 + added later under `docs/implementation-plans/2026-04-17-labeler-report-stage/`. 62 + Both trees are historical and useful for grepping rationale on individual 63 + decisions. 53 64 54 65 ## Conventions 55 66 ··· 70 81 ## Testing pattern 71 82 72 83 Every network-touching stage goes through an injected trait object 73 - (`HttpClient`, `DnsResolver`, `RawHttpTee`, `WebSocketClient`). Production 74 - wires in `Real*` implementations; tests wire in fakes from 75 - `tests/common/mod.rs`. Do not add network calls that bypass these seams — 76 - they make stages untestable and break snapshot determinism. 84 + (`HttpClient`, `DnsResolver`, `RawHttpTee`, `WebSocketClient`, 85 + `CreateReportTee`, `PdsXrpcClient`). Production wires in `Real*` 86 + implementations; tests wire in fakes from `tests/common/mod.rs`. Do not 87 + add network calls that bypass these seams — they make stages untestable 88 + and break snapshot determinism. 77 89 78 90 Integration tests pin per-check output via insta snapshots. When a check's 79 91 rendered text, diagnostic code, or order changes, `cargo insta review` is the
+143 -37
src/commands/test/labeler/CLAUDE.md
··· 1 1 # test labeler 2 2 3 - Last verified: 2026-04-15 3 + Last verified: 2026-04-19 4 4 5 5 ## Purpose 6 6 7 7 Implements `atproto-devtool test labeler <target>`, a conformance suite that 8 - validates an atproto labeler across four stages — identity, HTTP, 9 - subscription, and crypto — and produces a structured report plus an exit 10 - code (0 if all spec-required checks pass, 1 otherwise). Each stage is built 11 - around an injected I/O seam so integration tests can replay fixtures 12 - instead of talking to real servers. 8 + validates an atproto labeler across five stages — identity, HTTP, 9 + subscription, crypto, and report — and produces a structured report plus an 10 + exit code (0 if all spec-required checks pass, 1 on spec violations, 2 on 11 + network failures only). Each stage is built around an injected I/O seam so 12 + integration tests can replay fixtures instead of talking to real servers. 13 13 14 14 ## Contracts 15 15 ··· 24 24 - `pipeline::run_pipeline(target, LabelerOptions) -> LabelerReport` — the 25 25 one orchestrator that every test hits. 26 26 - **Per-stage entry points**: `identity::run`, `http::run`, 27 - `subscription::run`, `crypto::run`. Each returns a `*StageOutput` with an 28 - `Option<*Facts>` (populated only when the stage succeeds enough to let 29 - downstream stages run) plus a `Vec<CheckResult>`. 27 + `subscription::run`, `crypto::run`, `create_report::run`. Each returns a 28 + `*StageOutput` with an `Option<*Facts>` (populated only when the stage 29 + succeeds enough to let downstream stages run, or `None` when there are no 30 + meaningful facts to carry forward) plus a `Vec<CheckResult>`. 30 31 - **Report shape**: `report::{LabelerReport, CheckResult, CheckStatus, 31 - Stage, SummaryCounts, ReportHeader, RenderConfig}`. Five-way 32 - `CheckStatus`: `Pass`, `SpecViolation`, `NetworkError`, `Advisory`, 33 - `Skipped`. Exit code semantics: `1` if any `SpecViolation` is 34 - recorded; else `2` if any `NetworkError` is recorded; else `0`. 35 - `SpecViolation` takes precedence over `NetworkError` so that a 36 - conformance bug is never masked by an unrelated reachability 37 - failure. `Advisory` and `Skipped` never influence the exit code. 32 + Stage, SummaryCounts, ReportHeader, RenderConfig}`. `Stage` has five 33 + variants — `Identity`, `Http`, `Subscription`, `Crypto`, `Report` — and 34 + derives `Ord` in that declaration order, which is the rendering order 35 + (`Report` is always last). Five-way `CheckStatus`: `Pass`, 36 + `SpecViolation`, `NetworkError`, `Advisory`, `Skipped`. Exit code 37 + semantics: `1` if any `SpecViolation` is recorded; else `2` if any 38 + `NetworkError` is recorded; else `0`. `SpecViolation` takes precedence 39 + over `NetworkError` so that a conformance bug is never masked by an 40 + unrelated reachability failure. `Advisory` and `Skipped` never influence 41 + the exit code. 38 42 - **Check IDs are stable strings** (e.g. `"identity::target_resolved"`, 39 - `"http::first_page_decodes"`, `"crypto::rollup"`). They appear verbatim 40 - in insta snapshots under `tests/snapshots/`; renaming one is a breaking 41 - change to the CLI output contract. 43 + `"http::first_page_decodes"`, `"crypto::rollup"`, 44 + `"report::self_mint_accepted"`). They appear verbatim in insta snapshots 45 + under `tests/snapshots/`; renaming one is a breaking change to the CLI 46 + output contract. 42 47 - **Diagnostic codes are stable strings** (e.g. 43 - `"labeler::identity::labeler_endpoint_parseable"`). Same deal — snapshots 44 - pin them. 48 + `"labeler::identity::labeler_endpoint_parseable"`, 49 + `"labeler::report::contract_missing"`). Same deal — snapshots pin them. 50 + - **Report-stage surface**: `create_report::{Check, CheckFactsOutput, 51 + CreateReportFacts, CreateReportStageOutput, CreateReportStageError, 52 + CreateReportTee, RealCreateReportTee, RawCreateReportResponse, 53 + PdsXrpcClient, RealPdsXrpcClient, RawPdsXrpcResponse, 54 + PdsJwtFetcher, PdsProxiedPoster, CreateReportRunOptions, 55 + XrpcErrorEnvelope, RejectionShape}` plus per-check diagnostic structs 56 + (`ContractMissing`, `UnauthenticatedAccepted`, `MalformedBearerAccepted`, 57 + `WrongAudAccepted`, `WrongLxmAccepted`, `ExpiredAccepted`, `ShapeNot400`, 58 + `SelfMintRejected`, `PdsServiceAuthRejected`, `PdsProxiedRejected`). 59 + `Check::ORDER` is the canonical 10-element iteration order: contract, 60 + unauth, malformed, wrong-aud, wrong-lxm, expired, rejected-shape, 61 + self-mint, pds-service-auth, pds-proxied. Report stage always emits 62 + exactly 10 rows regardless of gating — missing identity facts collapse 63 + to 10 `Skipped` rows so row count and order are invariant. 45 64 46 65 ## Dependencies 47 66 48 67 - **Uses**: `crate::common::identity` for every network hop and DID 49 - primitive. `atrium-api` for labeler record + queryLabels types (we go 50 - through `serde_json` + atrium types, never through `atrium-xrpc-client`). 51 - `reqwest` and `tokio-tungstenite` only via the `RealHttpTee` and 52 - `RealWebSocketClient` seams. 68 + primitive (including `AnySigningKey`, `encode_multikey`, 69 + `is_local_labeler_hostname`). `crate::common::jwt` for hand-rolled 70 + compact JWS encoding used by the report stage. `atrium-api` for labeler 71 + record + queryLabels types (we go through `serde_json` + atrium types, 72 + never through `atrium-xrpc-client`). `reqwest` and `tokio-tungstenite` 73 + only via the `RealHttpTee`, `RealWebSocketClient`, `RealCreateReportTee`, 74 + and `RealPdsXrpcClient` seams. 53 75 - **Used by**: `crate::cli` wires this into the clap command tree; nothing 54 76 else depends on it. 55 77 - **Boundary**: Stage modules talk to each other only through `*Facts` 56 78 structs passed by `pipeline::run_pipeline`. A stage must not import 57 - another stage's internals. 79 + another stage's internals. The report stage reads 80 + `IdentityFacts::{reason_types, subject_types, subject_collections}` that 81 + identity is responsible for populating from the labeler record. 58 82 59 83 ## Key decisions 60 84 61 85 - **Every I/O boundary is a trait**: `HttpClient` + `DnsResolver` from 62 - `common::identity`, plus stage-local `RawHttpTee` (HTTP stage) and 63 - `WebSocketClient` / `FrameStream` (subscription stage). All four are 64 - injectable through `LabelerOptions`. The CLI passes real clients; tests 65 - pass fakes from `tests/common/mod.rs`. 86 + `common::identity`, plus stage-local `RawHttpTee` (HTTP stage), 87 + `WebSocketClient` / `FrameStream` (subscription stage), `CreateReportTee` 88 + (report stage, POST with optional bearer), and `PdsXrpcClient` (report 89 + stage, POST/GET against the user's PDS with optional bearer and 90 + `atproto-proxy` headers). All are injectable through `LabelerOptions`. 91 + The CLI passes real clients; tests pass fakes from `tests/common/mod.rs`. 66 92 - **Shared reqwest client**: `LabelerCmd::run` builds one reqwest client 67 93 with rustls + 10s timeout + user-agent and threads it through every 68 94 stage. Do not construct fresh clients inside stages. ··· 99 125 `subscribeLabels` frame are both exercised. Subscription samples are 100 126 capped at `subscription::SAMPLE_LABEL_CAP` to bound memory on noisy 101 127 streams. 128 + - **Report stage runs last and always emits 10 rows**: the report stage 129 + is ordered after crypto because it exercises write-side conformance 130 + (authenticated `createReport`), not observational conformance. The 131 + stage's output row count is a hard invariant — missing identity facts, 132 + missing contract, or absent self-mint / PDS inputs all collapse to 133 + `Skipped` rows rather than fewer rows. `Check::ORDER` is the frozen 134 + iteration sequence. 135 + - **`--commit-report` is the write-side opt-in**: without it the stage 136 + still emits all 10 rows (mostly `Skipped`), but it will not POST 137 + authenticated report bodies to the labeler. `ContractPublished` without 138 + `--commit-report` is a stage-skip; with `--commit-report`, missing 139 + `reasonTypes` / `subjectTypes` becomes a `SpecViolation` gating the 140 + rest of the stage. 141 + - **Self-mint only runs for locally-reachable labelers by default**: 142 + `is_local_labeler_hostname` classifies the labeler endpoint; non-local 143 + hosts skip all self-mint checks because the tool's local did:web doc 144 + server can't be reached from a public labeler. `--force-self-mint` 145 + overrides the heuristic. The `SelfMintSigner` (owner of the ephemeral 146 + did:web HTTP server + signing key) is constructed pessimistically in 147 + `LabelerCmd::run` so the stage can skip cheaply when locality fails. 148 + - **PDS-mediated modes are credentials-gated**: the 149 + `pds_service_auth_accepted` and `pds_proxied_accepted` checks require 150 + `--handle` + `--app-password` (enforced as a symmetric clap `requires`). 151 + The pipeline constructs a `RealPdsXrpcClient` only when credentials are 152 + present and identity produced a PDS endpoint; otherwise the checks 153 + emit `Skipped` with a reason. 154 + - **Sentinel reason string and run-id**: every committed report body 155 + carries a sentinel reason built by `create_report::sentinel::build` 156 + that encodes the run-id (16 hex chars from `getrandom`) and an RFC 3339 157 + UTC timestamp formatted by hand. This makes accidental reports easy to 158 + filter out of moderation queues. The run-id is generated once in 159 + `LabelerCmd::run` and threaded through `CreateReportRunOptions::run_id`. 102 160 103 161 ## Invariants 104 162 ··· 108 166 - `LabelerReport::exit_code` returns `1` if any `SpecViolation` is 109 167 recorded, `2` if not but at least one `NetworkError` is recorded, 110 168 and `0` otherwise. Advisories and skipped checks never fail the run. 169 + - The report stage always records exactly 10 `report::*` rows in 170 + `Check::ORDER` order. Tests (`labeler_report::ac7_1_row_count`, 171 + `labeler_report::ac7_2_canonical_order`) pin this. Any future check 172 + addition/removal is a wire contract change. 111 173 - Snapshot tests under `tests/snapshots/` are part of the contract. Any 112 174 check ID, diagnostic code, or rendered line change must be accompanied 113 - by a reviewed `cargo insta review`. 175 + by a reviewed `cargo insta review`. Rendered `elapsed: Xms` lines are 176 + normalized by `tests::common::normalize_timing` so per-run timing does 177 + not churn snapshots. 114 178 - The pipeline never calls `reqwest::Client::new()` or constructs a 115 179 tokio-tungstenite connection outside of `Real*` seam structs. 116 180 117 181 ## Key files 118 182 119 - - `labeler.rs` — clap args, `LabelerCmd::run`, CLI bootstrap. 120 - - `pipeline.rs` — `LabelerTarget`, `LabelerOptions`, `parse_target`, 183 + - `labeler.rs` — clap args, `LabelerCmd::run`, CLI bootstrap. New flags: 184 + `--commit-report`, `--force-self-mint`, `--self-mint-curve`, 185 + `--report-subject-did`, `--handle`, `--app-password` (the last two are 186 + symmetrically `requires`-bound). 187 + - `pipeline.rs` — `LabelerTarget`, `LabelerOptions` (now carries 188 + `create_report_tee`, `commit_report`, `force_self_mint`, 189 + `self_mint_curve`, `report_subject_override`, `self_mint_signer`, 190 + `pds_credentials`, `pds_xrpc_client` / `pds_xrpc_client_override`, 191 + `run_id`), `CreateReportTeeKind`, `PdsCredentials`, `parse_target`, 121 192 `run_pipeline` orchestration. 122 193 - `report.rs` — `CheckStatus`, `CheckResult`, `LabelerReport`, 123 - `RenderConfig`, rendering via `miette::GraphicalReportHandler`. 194 + `RenderConfig`, `Stage` (now 5 variants ending in `Report`), rendering 195 + via `miette::GraphicalReportHandler`. 124 196 - `identity.rs` — identity stage: DID resolution, labeler record fetch 125 197 (through `atrium-api` types over the `HttpClient` seam), policy 126 - validation. 198 + validation. `IdentityFacts` now also carries 199 + `reason_types` / `subject_types` / `subject_collections` extracted 200 + from the labeler record so the report stage can check contract shape 201 + without re-parsing. 127 202 - `http.rs` — HTTP stage: `RawHttpTee` trait, `RealHttpTee` reqwest 128 203 implementation, first-page / pagination / cursor checks against 129 204 `com.atproto.label.queryLabels`. ··· 131 206 traits, CBOR frame decoder, two-connection backfill / live-tail logic. 132 207 - `crypto.rs` — label canonicalization, signature verification, PLC key 133 208 history fallback. 209 + - `create_report.rs` — report stage entry point `create_report::run`, 210 + `CreateReportTee` + `RealCreateReportTee` seam, `PdsXrpcClient` + 211 + `RealPdsXrpcClient` seam, `PdsJwtFetcher` and `PdsProxiedPoster` 212 + helpers, `Check` enum (10 variants with stable IDs and 213 + `Check::ORDER`), diagnostic structs (`ContractMissing` + 9 per-check 214 + accepted/rejected variants), `XrpcErrorEnvelope` + `RejectionShape` 215 + classifier for 401 envelope checks, `build_minimal_report_body`. 216 + - `create_report/sentinel.rs` — sentinel reason-string builder, RFC 3339 217 + UTC formatter, `new_run_id` (16-hex-char run identifier via 218 + `getrandom`). 219 + - `create_report/did_doc_server.rs` — `DidDocServer`: an RAII 220 + 127.0.0.1:0-bound HTTP/1.1 server that serves a one-shot did:web 221 + document so the labeler can resolve our self-mint identity. 222 + - `create_report/self_mint.rs` — `SelfMintSigner` (owns signing key + 223 + `DidDocServer`) and `SelfMintCurve` clap `ValueEnum` (`es256`, 224 + `es256k`). 225 + - `create_report/pollution.rs` — pollution-avoidance helpers 226 + `choose_reason_type` and `choose_subject` so committing checks never 227 + submit plausible moderation content. 134 228 135 229 ## Gotchas 136 230 137 231 - `LabelerTarget::Endpoint { did: None }` runs HTTP and subscription but 138 - skips identity and crypto. Emitting those as "blocked" rather than 139 - "skipped — no DID supplied" is a regression. 232 + skips identity, crypto, and report. Emitting those as "blocked" rather 233 + than "skipped — no DID supplied" is a regression. 140 234 - `RawHttpTee::query_labels(cursor)` must NOT duplicate the first-page 141 235 request for reachability — the stage previously pinged before the real 142 236 request, doubling traffic against real servers. ··· 150 244 - Fixture layout under `tests/fixtures/labeler/<stage>/<case>/` is 151 245 referenced by test helper `gen_fixtures` anchored to 152 246 `CARGO_MANIFEST_DIR`. Empty case directories need a `.gitkeep`. 247 + - The report stage's `SelfMintSigner::spawn` binds a TCP port and starts 248 + a tokio task; it's constructed only when needed (local endpoint or 249 + `--force-self-mint`) to avoid orphaning a server on every run. Do not 250 + move the allocation earlier in `LabelerCmd::run`. 251 + - PDS-mediated modes create a `createSession` exactly once per run; the 252 + resulting access JWT is reused across `getServiceAuth` and the 253 + proxied POST. An `I2`-style regression (double `createSession`) is 254 + visible through `FakePdsXrpcClient::request_count` in tests. 255 + - `normalize_timing` in `tests/common/mod.rs` rewrites the `elapsed: Xms` 256 + footer to a fixed token before snapshot comparison. Report-stage 257 + integration tests depend on this — do not write report-stage snapshots 258 + without going through it.
+85 -13
src/common/CLAUDE.md
··· 1 1 # common 2 2 3 - Last verified: 2026-04-15 3 + Last verified: 2026-04-19 4 4 5 5 ## Purpose 6 6 7 7 Narrow, mockable primitives shared by every labeler conformance stage. 8 8 `common::identity` exists so that every network hop — DNS, HTTPS, PLC 9 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. 10 + swapped with a recorded fixture in integration tests. It also owns the 11 + signing/verifying key newtypes (`AnySigningKey`, `AnyVerifyingKey`, 12 + `AnySignature`) that every curve-generic operation routes through. 13 + `common::jwt` holds a minimal hand-rolled compact JWS encoder/decoder 14 + used by the report stage to mint self-mint service-auth tokens without 15 + pulling a JWT library. `common::diagnostics` holds the miette 16 + `NamedSource`/`SourceSpan` helpers used when attaching JSON source 17 + context to check failures. 13 18 14 19 ## Contracts 15 20 ··· 21 26 constructible from a shared client via `from_client` so stages can reuse 22 27 one TLS pool) and `RealDnsResolver` (hickory). 23 28 - Types: `Did`, `DidMethod`, `DidDocument`, `RawDidDocument`, `Service`, 24 - `VerificationMethod`, `Curve`, `AnyVerifyingKey`, `AnySignature`, 25 - `ParsedMultikey`, `PlcHistoricKey`. 29 + `VerificationMethod`, `Curve`, `AnyVerifyingKey`, `AnySigningKey`, 30 + `AnySignature`, `ParsedMultikey`, `PlcHistoricKey`. 26 31 - Resolvers: `resolve_handle`, `resolve_did`, `find_service`, 27 - `parse_multikey`, `plc_history_for_fragment`. 32 + `parse_multikey`, `encode_multikey`, `plc_history_for_fragment`. 33 + - Classification: `is_local_labeler_hostname(&Url) -> bool` — returns 34 + `true` for loopback, `.local`, and RFC 1918 IPv4 addresses. Drives 35 + the report stage's self-mint viability check. 28 36 - `IdentityError` — single error enum covering every resolution failure. 29 37 Variants are matched on by the identity stage to emit distinct check 30 38 results, so adding or removing variants is a contract change. 39 + - **Signing API**: 40 + - `AnySigningKey::{K256, P256}` mirror `AnyVerifyingKey` for the 41 + signing side. `sign(msg)` and `sign_prehash(&[u8; 32])` return 42 + `AnySignature`. All signatures are low-s normalized — the k256 43 + backend does this automatically; the p256 backend normalizes 44 + explicitly because p256's `sign_prehash` can return high-s. 45 + - `AnySigningKey::verifying_key()` returns the paired `AnyVerifyingKey`. 46 + - `AnySigningKey::jwt_alg()` returns `"ES256K"` or `"ES256"`. 47 + - `AnySignature::to_jws_bytes() -> [u8; 64]` serializes `r || s` 48 + big-endian (JWS raw-signature form, NOT DER). 49 + - `encode_multikey(&AnyVerifyingKey) -> String` is the exact inverse 50 + of `parse_multikey`: base58btc multibase with the multicodec curve 51 + prefix (`0xe701` for secp256k1, `0x8024` for P-256) followed by the 52 + compressed SEC1 point. 53 + - **Exposes from `jwt`**: 54 + - Types: `JwtHeader`, `JwtClaims`, `JwtError`. Field names 55 + (`alg`, `typ`, `iss`, `aud`, `exp`, `iat`, `lxm`, `jti`) are the 56 + exact JSON keys atproto labelers expect — do NOT rename without 57 + adding `#[serde(rename = "...")]`. 58 + - Functions: `encode_compact(&header, &claims, &AnySigningKey)` for 59 + producing compact-form tokens, `decode_compact(token)` for parsing, 60 + `verify_compact(token, &AnyVerifyingKey)` for end-to-end verify. 61 + - Only ES256 and ES256K are supported. `nbf` is deliberately omitted 62 + — the atproto spec does not require it and some servers reject 63 + unexpected claims. 64 + - `JwtError` does NOT derive `miette::Diagnostic` with stable codes. 65 + It is surfaced only inside the stage, which wraps any failure in a 66 + stage-local diagnostic with a `labeler::report::*` code before 67 + rendering. 31 68 - **Guarantees**: 32 69 - `resolve_did` returns `RawDidDocument` with the original bytes retained 33 70 in an `Arc<[u8]>` so downstream stages can build `NamedSource` ··· 50 87 ## Dependencies 51 88 52 89 - **Uses**: `reqwest` (rustls, json, gzip), `hickory-resolver`, `k256`, 53 - `p256`, `multibase`, `sha2`, `serde_json`, `miette`, `thiserror`. 90 + `p256`, `multibase`, `sha2`, `serde_json`, `miette`, `thiserror`, 91 + `url`. `common::jwt` additionally uses `base64` (URL_SAFE_NO_PAD 92 + engine) and `serde`. 54 93 - **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. 94 + tests under `tests/`. `common::jwt` is currently only used by the 95 + report stage. 96 + - **Boundary**: `common::identity` and `common::jwt` must not depend on 97 + anything under `commands/`. Stage-specific types (`IdentityFacts`, 98 + `HttpFacts`, etc.) live next to their stage, not here. 59 99 60 100 ## Key decisions 61 101 ··· 73 113 before building the PLC directory URL. Do not regress. 74 114 - **No `#[serde(flatten)]` / `#[serde(untagged)]`**: Required by project 75 115 conventions; all DID document types use explicit `#[serde(rename)]`. 116 + - **All signatures low-s normalized**: `AnySigningKey::sign` guarantees 117 + low-s form for both curves so `AnyVerifyingKey::verify_prehash` round-trip 118 + always succeeds. atproto requires low-s; p256's backend does not enforce 119 + it, so we explicitly call `normalize_s`. 120 + - **Hand-rolled JWT instead of a library**: We only need compact JWS with 121 + ES256/ES256K for a handful of tightly-scoped report-stage tokens. A full 122 + JWT library would pull RSA, HMAC, JWE, and a JSON Schema validator we do 123 + not want. The module is <500 lines and fully covered by round-trip 124 + tests. 125 + - **`is_local_labeler_hostname` is deliberately conservative**: IPv6 126 + private ranges (`fc00::/7`, link-local) are NOT classified as local in 127 + v1. Operators running labelers on IPv6 ULA must pass `--force-self-mint`. 76 128 77 129 ## Invariants 78 130 ··· 83 135 - `HttpClient::get_bytes` returns the HTTP status even for non-2xx responses 84 136 rather than converting them to errors; callers decide what a non-200 85 137 means in context. 138 + - `AnySignature::to_jws_bytes()` is always exactly 64 bytes, for both 139 + curves. 140 + - `encode_multikey(parse_multikey(s).verifying_key) == s` for every 141 + well-formed atproto multikey — round-tripping is pinned by unit tests. 142 + - `jwt::verify_compact` accepts exactly three `.`-separated segments. 143 + Four-segment (JWE) or malformed inputs return `JwtError::MalformedCompact`. 86 144 87 145 ## Key files 88 146 89 - - `identity.rs` — all of the above, plus extensive unit tests at the bottom. 147 + - `identity.rs` — all resolvers, DID types, signing/verifying key 148 + newtypes, `encode_multikey` / `parse_multikey`, 149 + `is_local_labeler_hostname`, plus extensive unit tests at the bottom. 150 + - `jwt.rs` — compact JWS encoder/decoder for ES256 and ES256K: 151 + `JwtHeader`, `JwtClaims`, `JwtError`, `encode_compact`, 152 + `decode_compact`, `verify_compact`. Segments use unpadded base64url; 153 + signatures are raw `r || s` (not DER) per RFC 7518 §3.4. 90 154 - `diagnostics.rs` — `install_miette_handler`, `named_source_from_bytes` / 91 155 `named_source_from_str`, plus the JSON display helpers: 92 156 `pretty_json_for_display` (re-serialize a JSON body so miette's caret ··· 109 173 (oldest-first) and dedupes by multikey string, not by position — the 110 174 same key appearing across multiple rotations collapses to a single 111 175 `PlcHistoricKey` (keeping the earliest introduction's metadata). 176 + - `AnySigningKey` nonces (`jti` in JWTs, run-id in sentinels) come from 177 + `getrandom::getrandom`, not from `rand`. The crate is a direct dep 178 + because the transitive `rand_core` in `elliptic-curve` is built 179 + without the `getrandom` feature. 180 + - p256 `sign_prehash` returns signatures that may be high-s; always go 181 + through `AnySigningKey::sign` / `sign_prehash` rather than calling the 182 + backend trait directly, or low-s normalization will be skipped and 183 + atproto servers will reject the signature.