CLI app for developers prototyping atproto functionality
common#
Last verified: 2026-04-25
Purpose#
Narrow, mockable primitives shared across atproto-devtool's conformance
test subcommands. Five submodules:
common::identity— DNS, HTTPS, PLC directory, DID document, and labeler record primitives. Every network hop here is a trait object so integration tests can replay recorded fixtures instead of hitting real servers. Also owns the signing/verifying key newtypes (AnySigningKey,AnyVerifyingKey,AnySignature) that every curve-generic operation routes through.common::jwt— minimal hand-rolled compact JWS encoder/decoder used by the labeler report stage to mint self-mint service-auth tokens without pulling a JWT library. Only ES256 and ES256K.common::oauth— OAuth 2.0 / JOSE primitives for thetest oauth *family:clock(trait-object time source),jws(JWK parsing + ES256 signing/verification viajsonwebtoken), andrelying_party(atproto-spec Relying Party that drives PAR, PKCE S256, DPoP proofs, andprivate_key_jwtwith deterministic seeded RNG for reproducible tests).common::report— the cross-subcommand report contract (CheckStatus,CheckResult,Stage,LabelerReport,RenderConfig, plus theblocked_by/skipped_with_reasonskip-row constructors). Promoted out of the labeler tree so oauth_client can reuse it. Both subcommands render throughLabelerReport::render; the name is historical.common::diagnostics— mietteNamedSource/SourceSpanhelpers used when attaching JSON source context to check failures.
Contracts#
- Exposes from
identity:- Traits:
HttpClient(get_bytes(&Url) -> (u16, Vec<u8>)),DnsResolver(txt_lookup(&str) -> Vec<String>). These are the only sanctioned seams for network I/O in the whole crate. - Real implementations:
RealHttpClient(wrapsreqwest::Client, constructible from a shared client viafrom_clientso stages can reuse one TLS pool) andRealDnsResolver(hickory). - Types:
Did,DidMethod,DidDocument,RawDidDocument,Service,VerificationMethod,Curve,AnyVerifyingKey,AnySigningKey,AnySignature,ParsedMultikey,PlcHistoricKey. - Resolvers:
resolve_handle,resolve_did,find_service,parse_multikey,encode_multikey,plc_history_for_fragment. - Classification:
is_local_labeler_hostname(&Url) -> bool— returnstruefor loopback,.local, and RFC 1918 IPv4 addresses. Drives the report stage's self-mint viability check. IdentityError— single error enum covering every resolution failure. Variants are matched on by the identity stage to emit distinct check results, so adding or removing variants is a contract change.
- Traits:
- Signing API:
AnySigningKey::{K256, P256}mirrorAnyVerifyingKeyfor the signing side.sign(msg)andsign_prehash(&[u8; 32])returnAnySignature. All signatures are low-s normalized — the k256 backend does this automatically; the p256 backend normalizes explicitly because p256'ssign_prehashcan return high-s.AnySigningKey::verifying_key()returns the pairedAnyVerifyingKey.AnySigningKey::jwt_alg()returns"ES256K"or"ES256".AnySignature::to_jws_bytes() -> [u8; 64]serializesr || sbig-endian (JWS raw-signature form, NOT DER).encode_multikey(&AnyVerifyingKey) -> Stringis the exact inverse ofparse_multikey: base58btc multibase with the multicodec curve prefix (0xe701for secp256k1,0x8024for P-256) followed by the compressed SEC1 point.
- Exposes from
jwt:- Types:
JwtHeader,JwtClaims,JwtError. Field names (alg,typ,iss,aud,exp,iat,lxm,jti) are the exact JSON keys atproto labelers expect — do NOT rename without adding#[serde(rename = "...")]. - Functions:
encode_compact(&header, &claims, &AnySigningKey)for producing compact-form tokens,decode_compact(token)for parsing,verify_compact(token, &AnyVerifyingKey)for end-to-end verify. - Only ES256 and ES256K are supported.
nbfis deliberately omitted — the atproto spec does not require it and some servers reject unexpected claims. JwtErrordoes NOT derivemiette::Diagnosticwith stable codes. It is surfaced only inside the stage, which wraps any failure in a stage-local diagnostic with alabeler::report::*code before rendering.
- Types:
- Exposes from
oauth::clock:- Trait:
Clock(now_unix_seconds(&self) -> u64). The only sanctioned time source for anything the OAuth tests observe. Passed asArc<dyn Clock>so both the pipeline and the in-process RP can share one instance. - Real implementation:
RealClock. Tests injectFakeClockfromtests/common/mod.rs.
- Trait:
- Exposes from
oauth::jws:- Enums:
JwsAlg(Es256|Es256k),JwkUse(Sig|Other(Arc<str>)). - Types:
ParsedJwk(kid, alg, alg_raw, r#use, pre-built ES256 verifier), plus the error typeJwsError(#[derive(Diagnostic)]withoauth_client::jws::*codes). - Functions:
parse_jwk,sign_jws,verify_jws. sign_jws/verify_jwsonly support ES256. ES256K parses but signing and verification returnJwsError::UnsupportedOperation— handled at the JWKS stage as a curve-not-modern-EC violation rather than a hard structural failure.ParsedJwk::decoding_keyisSomewhenever the JWK is on P-256 and either declaresalg = ES256or omitsalg(RFC 7517 marksalgas optional, and real-world DPoP proofs routinely embed a JWK with noalgbecause the JWT header already carries it). It isNonefor secp256k1 or for a P-256 JWK that explicitly declares an unsupportedalg(the caller's intent is respected rather than overridden by the curve hint).
- Enums:
- Exposes from
oauth::relying_party:- Enum:
ClientKind(Confidential|Public). - Types:
RelyingParty,AsDescriptor,ParRequest,ParResponse,TokenResponse(with a publicsub: Option<String>field — atproto mandates the token endpoint return the authenticated account's DID),AuthorizeOutcome,RpError(#[derive(Diagnostic)]withoauth_client::relying_party::*codes). RpErrorvariants carry stable diagnostic codes; the current set includesissuer_mismatch(IssuerMismatch { expected, got }, raised bydo_authorizewhen the AS redirect'sissquery parameter does not matchAsDescriptor::issuer) andsub_mismatch(SubMismatch { expected, got: Option<String> }, raised bydo_token/do_refreshwhen the token response'ssubis missing or differs from the DID the flow is pinned to).do_authorize,do_token, anddo_refreshperformiss/subverification internally;do_tokenanddo_refreshtake anexpected_sub: &strargument. For modeling non-conformant clients, the sibling helpersdo_authorize_skipping_iss_verification,do_token_skipping_sub_verification, anddo_refresh_skipping_sub_verificationreturn the raw response with no verification — these are test-surface only and drive theiss_sub_verificationinteractive sub-stage.- Trait:
RpFactory(fn build(&self, client_id: Url, kind: ClientKind) -> RelyingParty; callers typically hold anArc<dyn RpFactory>). Two implementations:DefaultRpFactory(production; fresh OS entropy per call) andDeterministicRpFactory(tests; seededChaCha20Rngfor reproducible DPoP proofs and PKCE verifiers). - DPoP nonce rotation is handled inside
RelyingParty— callers do not plumb nonces through.
- Enum:
- Exposes from
report:- Enum:
CheckStatus(Pass|SpecViolation|NetworkError|Advisory|Skipped). Rendering glyphs ([OK]/[FAIL]/[NET]/[WARN]/[SKIP]) and per-status ANSI colors are part of the public contract — snapshot tests pin them. - Struct:
CheckResult { id: &'static str, stage: Stage, status, summary, diagnostic: Option<Box<dyn Diagnostic + Send + Sync>>, skipped_reason }. - Newtype:
Stage(pub &'static str)withlabel()and the constantsIDENTITY,HTTP,SUBSCRIPTION,CRYPTO,REPORT(labeler) plusDISCOVERY,METADATA,JWKS,INTERACTIVE(oauth client). UseStage::<NAME>rather than constructing ad-hoc stages — the render loop groups by stage identity and stable heading strings appear in snapshots. - Struct:
LabelerReport { header, results, started_at, finished_at }withrecord,finish,exit_code,summary_counts, andrender(&mut W, &RenderConfig). The name is historical; the OAuth client subcommand wraps it inOauthClientReportrather than duplicating the rendering logic. - Helpers:
blocked_by(check_id, stage, summary, blocker_check_id)producesSkippedwithreason = "blocked by <blocker_check_id>".skipped_with_reasonproducesSkippedwith an arbitrary reason for non-blocking skips (e.g. "metadata is implicit for loopback clients"). - Exit code contract:
1if anySpecViolation, else2if anyNetworkError, else0.AdvisoryandSkippednever fail a run.SpecViolationtakes precedence overNetworkError.
- Enum:
- Guarantees:
resolve_didreturnsRawDidDocumentwith the original bytes retained in anArc<[u8]>so downstream stages can buildNamedSourcediagnostics that point back at the unmodified server response.parse_multikeyaccepts onlydid:key/ multibase-zprefixed inputs and rejects unknown codec prefixes, wrong lengths, and mismatched curves. Never panics on malformed input.find_servicematches on the trailing#fragmentofService::idanchored to the end of the string (not a substring search).plc_history_for_fragmentreturns the set of distinct keys from the PLC audit log for a given fragment, deduplicated by multikey string (keeping the earliest introduction). Order is chronological (oldest-first, matching the PLC API wire order), but the crypto stage treats the result as a set.
- Expects: Callers supply a
&dyn HttpClient/&dyn DnsResolverrather than constructing their own reqwest/hickory instances. The CLI wires the real clients once inLabelerCmd::runand passes them throughLabelerOptions.
Dependencies#
- Uses:
reqwest(rustls, json, gzip),hickory-resolver,k256,p256(withpkcs8feature for the RP),multibase,sha2,serde_json,serde_urlencoded(OAuth form bodies),miette,thiserror,jsonwebtoken(rust_crypto backend, foroauth::jws),rand_chacha+rand_core(for the deterministic RP RNG),url.common::jwtadditionally usesbase64(URL_SAFE_NO_PAD engine) andserde. - Used by: every stage in
commands/test/labeler/andcommands/test/oauth/client/, plus integration tests undertests/.common::jwtis currently only used by the labeler report stage. - Boundary: nothing in
common/depends on anything undercommands/. Stage-specific types (IdentityFacts,DiscoveryFacts,MetadataFacts, etc.) live next to their stage, not here.common::reportis the only cross-subcommand coupling — both labeler and oauth_client share itsCheckStatus/Stage/LabelerReportcontracts, and changes to those ripple through every snapshot in the crate.
Key decisions#
- Narrow trait seams, not
reqwest::Clienteverywhere: Every previous refactor that tried to passreqwest::Clientdirectly eventually broke integration tests. TheHttpClienttrait's two-method surface is deliberately small so fakes are trivial to write. Arc<[u8]>for source bytes: mietteNamedSourcewants owned bytes and we fan the same payload out to multiple diagnostics, so every raw response is stored asArc<[u8]>.- Single
IdentityErrorenum: We tried split-per-stage errors and it forced lossy conversions. One enum, matched exhaustively by the identity stage, is less code and produces better diagnostics. did:plcpercent-encoding:resolve_didpercent-encodes the DID before building the PLC directory URL. Do not regress.- No
#[serde(flatten)]/#[serde(untagged)]: Required by project conventions; all DID document types use explicit#[serde(rename)]. - All signatures low-s normalized:
AnySigningKey::signguarantees low-s form for both curves soAnyVerifyingKey::verify_prehashround-trip always succeeds. atproto requires low-s; p256's backend does not enforce it, so we explicitly callnormalize_s. - Hand-rolled JWT instead of a library: We only need compact JWS with ES256/ES256K for a handful of tightly-scoped report-stage tokens. A full JWT library would pull RSA, HMAC, JWE, and a JSON Schema validator we do not want. The module is <500 lines and fully covered by round-trip tests.
is_local_labeler_hostnameis deliberately conservative: IPv6 private ranges (fc00::/7, link-local) are NOT classified as local in v1. Operators running labelers on IPv6 ULA must pass--force-self-mint.
Invariants#
- A
RawDidDocumentalways hassource_bytesmatching exactly what the server returned — never pretty-printed, never re-serialized. AnyVerifyingKey::verify_prehashrejects curve mismatches rather than silently returningOk(()).HttpClient::get_bytesreturns the HTTP status even for non-2xx responses rather than converting them to errors; callers decide what a non-200 means in context.AnySignature::to_jws_bytes()is always exactly 64 bytes, for both curves.encode_multikey(parse_multikey(s).verifying_key) == sfor every well-formed atproto multikey — round-tripping is pinned by unit tests.jwt::verify_compactaccepts exactly three.-separated segments. Four-segment (JWE) or malformed inputs returnJwtError::MalformedCompact.
Key files#
identity.rs— all resolvers, DID types, signing/verifying key newtypes,encode_multikey/parse_multikey,is_local_labeler_hostname, plus extensive unit tests at the bottom.jwt.rs— compact JWS encoder/decoder for ES256 and ES256K:JwtHeader,JwtClaims,JwtError,encode_compact,decode_compact,verify_compact. Segments use unpadded base64url; signatures are rawr || s(not DER) per RFC 7518 §3.4.oauth/clock.rs—Clocktrait andRealClock.oauth/jws.rs—JwsAlg,JwkUse,ParsedJwk,JwsError,parse_jwk,sign_jws,verify_jws. AllJwsErrorvariants carry stableoauth_client::jws::*diagnostic codes pinned in snapshots.oauth/relying_party.rs—RelyingParty,RpFactory,DefaultRpFactory,DeterministicRpFactory, and the request/response types for PAR / authorize / token / refresh. Signing keys are generated from a seededChaCha20Rngwhen built throughDeterministicRpFactory.report.rs—CheckStatus,CheckResult,Stage,LabelerReport,RenderConfig,blocked_by,skipped_with_reason. Unit tests at the bottom pin exit-code and render behavior.diagnostics.rs—install_miette_handler,named_source_from_bytes/named_source_from_str, plus the JSON display helpers:pretty_json_for_display(re-serialize a JSON body so miette's caret rendering has newlines to land on),span_at_line_column(convert aserde_json::Error(line, column)into aSourceSpan, clamped to the matched line), andspan_for_quoted_literal(find the span of a quoted JSON key or string value). Every stage that attaches JSON source context to a diagnostic should pretty-print the body once up front and compute spans against that same pretty body — never mix raw and pretty.
Gotchas#
RealHttpClient::new()builds a freshreqwest::Clientwith a 10s timeout and a User-Agent header. Production code should preferRealHttpClient::from_clientso identity, HTTP, and crypto stages all share one connection pool.resolve_handlehas a DNS-first / HTTPS-fallback order and the HTTPS fallback must send a User-Agent. Tests exercise both paths.plc_history_for_fragmenttraverses the PLC audit log in wire order (oldest-first) and dedupes by multikey string, not by position — the same key appearing across multiple rotations collapses to a singlePlcHistoricKey(keeping the earliest introduction's metadata).AnySigningKeynonces (jtiin JWTs, run-id in sentinels) come fromgetrandom::getrandom, not fromrand. The crate is a direct dep because the transitiverand_coreinelliptic-curveis built without thegetrandomfeature.- p256
sign_prehashreturns signatures that may be high-s; always go throughAnySigningKey::sign/sign_prehashrather than calling the backend trait directly, or low-s normalization will be skipped and atproto servers will reject the signature.