CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

feat(identity): accept local http:// endpoints and downgrade mismatches to advisory

Two companion relaxations so `test labeler http://localhost:8080 --did
did:plc:<prod>` can drive the full pipeline against a local copy of a
production labeler:

1. The `labeler::identity::labeler_endpoint_is_https` check accepts an
`http://` DID-doc service endpoint when its hostname is classified as
local. Remote HTTP endpoints still produce a `SpecViolation` with an
error message that now names the local exception.

2. The `labeler::identity::resolved_did_matches_flag` check now emits
`Advisory` (not `SpecViolation`) when the `--target http://<local>`
URL disagrees with the DID document's published endpoint, and
`IdentityFacts.labeler_endpoint` is overridden to the local URL so
every downstream stage (HTTP, subscription, report) talks to the
local copy instead of the published production endpoint. Remote
URL mismatches remain a hard `SpecViolation`.

Adds an `advisory` builder to the identity `Check` enum and a new
`local_http_override_mismatch_is_advisory` integration test that pins
the full rendered pipeline output via snapshot. The pre-existing
`non_https_endpoint_renders_spec_violation` snapshot is regenerated
because the scheme-check error message text changed.

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

authored by

Jack Grigg
Claude Opus 4.7
and committed by
Tangled
d0188cc6 6bb4c1c0

+213 -20
+9
src/commands/test/labeler/CLAUDE.md
··· 104 104 history, so a failure there is a hard `SpecViolation`. Verification that 105 105 only succeeds against a historic key still passes the stage but emits an 106 106 `Advisory`. 107 + - **Identity stage downgrades local endpoint mismatches to Advisory**: 108 + when the user supplies `--target http://<local>:<port> --did <prod-did>` 109 + and the DID document advertises a different (production) endpoint, 110 + `identity::resolved_did_matches_flag` emits `Advisory` rather than 111 + `SpecViolation`, and `IdentityFacts.labeler_endpoint` is overridden to 112 + the local URL so HTTP / subscription / report stages all target the 113 + local copy. Without this override the `block_facts = true` branch would 114 + skip the report stage entirely, which is the opposite of what the 115 + developer wanted. Remote-URL mismatches remain `SpecViolation`. 107 116 - **DRISL-CBOR canonicalization for label signing**: crypto stage 108 117 implements the deterministic CBOR canonicalization in 109 118 `canonicalize_label_for_signing` rather than pulling a library — label
+65 -19
src/commands/test/labeler/identity.rs
··· 19 19 }; 20 20 use crate::common::identity::{ 21 21 AnyVerifyingKey, Did, DidDocument, DnsResolver, HttpClient, IdentityError, RawDidDocument, 22 - find_service, parse_multikey, resolve_did, resolve_handle, 22 + find_service, is_local_labeler_hostname, parse_multikey, resolve_did, resolve_handle, 23 23 }; 24 24 25 25 /// The fetched labeler record with parsed policies and optional field lists. ··· 392 392 } 393 393 } 394 394 395 + pub fn advisory( 396 + self, 397 + diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 398 + ) -> CheckResult { 399 + CheckResult { 400 + id: self.id(), 401 + stage: Stage::Identity, 402 + status: CheckStatus::Advisory, 403 + summary: Cow::Borrowed(self.summary_str()), 404 + diagnostic, 405 + skipped_reason: None, 406 + } 407 + } 408 + 395 409 /// Skip this check because a prerequisite check failed. 396 410 pub fn blocked_by(self, prerequisite: Check) -> CheckResult { 397 411 self.skip(format!("blocked by {}", prerequisite.id())) ··· 530 544 // Check::LabelerEndpointParseable and Check::LabelerEndpointIsHttps. 531 545 // If the service is missing, both checks are blocked. If the URL is 532 546 // unparseable, the scheme check is blocked by the parseable check. 533 - let labeler_endpoint: Option<Url> = match labeler_service { 547 + // 548 + // `mut` so the ResolvedDidMatchesFlag branch below can substitute the 549 + // user's local override URL into `IdentityFacts.labeler_endpoint` when 550 + // a local `--target` endpoint disagrees with the DID document. 551 + let mut labeler_endpoint: Option<Url> = match labeler_service { 534 552 None => { 535 553 results.push(Check::LabelerEndpointParseable.blocked_by(Check::LabelerServicePresent)); 536 554 results.push(Check::LabelerEndpointIsHttps.blocked_by(Check::LabelerServicePresent)); ··· 539 557 Some(svc) => match Url::parse(&svc.service_endpoint) { 540 558 Ok(url) => { 541 559 results.push(Check::LabelerEndpointParseable.pass()); 542 - if url.scheme() != "https" { 560 + // HTTPS is the default accepted scheme. Plaintext HTTP is also 561 + // accepted when the hostname is local (loopback, RFC 1918, 562 + // `.local` mDNS) so developers can target a labeler running on 563 + // their own machine or LAN. 564 + let is_https = url.scheme() == "https"; 565 + let is_http_local = url.scheme() == "http" && is_local_labeler_hostname(&url); 566 + if !is_https && !is_http_local { 543 567 let span = 544 568 span_for_quoted_literal(display_doc_bytes.as_ref(), &svc.service_endpoint); 545 569 let diag = Box::new(NonHttpsLabelerEndpointError { 546 570 message: format!( 547 - "Labeler endpoint must use HTTPS, got: {}", 571 + "Labeler endpoint must use HTTPS (or HTTP with a local hostname), got: {}", 548 572 svc.service_endpoint 549 573 ), 550 574 named_source: NamedSource::new( ··· 596 620 }, 597 621 Some(resolved_endpoint), 598 622 ) => { 599 - if !endpoints_match(flag_url, resolved_endpoint) { 623 + if endpoints_match(flag_url, resolved_endpoint) { 624 + results.push(Check::ResolvedDidMatchesFlag.pass()); 625 + } else { 600 626 // Search for the raw endpoint string from the DID doc's service entry. 601 627 let service = 602 628 find_service(&raw_did_doc.parsed, "atproto_labeler", "AtprotoLabeler"); 603 629 let span = service.and_then(|svc| { 604 630 span_for_quoted_literal(display_doc_bytes.as_ref(), &svc.service_endpoint) 605 631 }); 606 - let diag = Box::new(EndpointMismatchError { 607 - message: format!( 608 - "DID document endpoint ({resolved_endpoint}) does not match provided endpoint ({flag_url})" 609 - ), 610 - named_source: NamedSource::new( 611 - raw_did_doc.source_name.clone(), 612 - display_doc_bytes.clone(), 613 - ), 614 - span, 615 - }); 616 - block_facts = true; 617 - results.push(Check::ResolvedDidMatchesFlag.spec_violation(Some(diag))); 618 - } else { 619 - results.push(Check::ResolvedDidMatchesFlag.pass()); 632 + 633 + if is_local_labeler_hostname(flag_url) { 634 + // The user is targeting a local copy of the labeler. The 635 + // production DID document won't advertise a localhost URL, 636 + // so a mismatch here is expected — surface it as Advisory 637 + // so downstream stages still run, and substitute the local 638 + // URL into IdentityFacts so HTTP/subscription/report all 639 + // talk to the local copy instead of the published endpoint. 640 + let diag = Box::new(EndpointMismatchError { 641 + message: format!( 642 + "DID document endpoint ({resolved_endpoint}) does not match local override ({flag_url}); using the local URL for the remaining stages" 643 + ), 644 + named_source: NamedSource::new( 645 + raw_did_doc.source_name.clone(), 646 + display_doc_bytes.clone(), 647 + ), 648 + span, 649 + }); 650 + results.push(Check::ResolvedDidMatchesFlag.advisory(Some(diag))); 651 + labeler_endpoint = Some(flag_url.clone()); 652 + } else { 653 + let diag = Box::new(EndpointMismatchError { 654 + message: format!( 655 + "DID document endpoint ({resolved_endpoint}) does not match provided endpoint ({flag_url})" 656 + ), 657 + named_source: NamedSource::new( 658 + raw_did_doc.source_name.clone(), 659 + display_doc_bytes.clone(), 660 + ), 661 + span, 662 + }); 663 + block_facts = true; 664 + results.push(Check::ResolvedDidMatchesFlag.spec_violation(Some(diag))); 665 + } 620 666 } 621 667 } 622 668 (
+84
tests/labeler_identity.rs
··· 757 757 758 758 insta::assert_snapshot!(rendered); 759 759 } 760 + 761 + /// Local override: user supplies a local `http://` target together with a 762 + /// `--did` that resolves to a production DID document whose 763 + /// `#atproto_labeler` service advertises a remote endpoint. Expected 764 + /// behaviour: 765 + /// 766 + /// - `identity::resolved_did_matches_flag` emits `Advisory`, NOT 767 + /// `SpecViolation` — the mismatch is expected when testing a local copy 768 + /// of a production labeler. 769 + /// - The rendered header's labeler endpoint is the local override, so 770 + /// HTTP / subscription / report stages talk to the local copy. 771 + /// - Downstream stages run to completion rather than being blocked by 772 + /// identity. 773 + #[tokio::test] 774 + async fn local_http_override_mismatch_is_advisory() { 775 + let http = FakeHttpClient::new(); 776 + let dns = FakeDnsResolver::new(); 777 + 778 + let did_json = include_bytes!("fixtures/labeler/identity/endpoint_mismatch/did.json").to_vec(); 779 + http.add_response( 780 + "https://plc.directory/did:plc:endpoint_mismatch_test_123456789", 781 + 200, 782 + did_json, 783 + ); 784 + 785 + let labeler_record_json = 786 + include_bytes!("fixtures/labeler/identity/endpoint_mismatch/labeler_record.json").to_vec(); 787 + http.add_response( 788 + "https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=did:plc:endpoint_mismatch_test_123456789&collection=app.bsky.labeler.service&rkey=self", 789 + 200, 790 + labeler_record_json, 791 + ); 792 + 793 + // Target is a local labeler copy; the DID document points somewhere else. 794 + let target = parse_target( 795 + "http://localhost:8080", 796 + Some("did:plc:endpoint_mismatch_test_123456789"), 797 + ) 798 + .expect("parse failed"); 799 + 800 + let fake_tee = common::FakeRawHttpTee::new(); 801 + let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 802 + fake_tee.add_response(None, 200, empty_response); 803 + 804 + let fake_ws = common::FakeWebSocketClient::empty(); 805 + let fake_report_tee = common::FakeCreateReportTee::new(); 806 + 807 + let opts = LabelerOptions { 808 + http: &http, 809 + dns: &dns, 810 + http_tee: HttpTee::Test(&fake_tee), 811 + ws_client: Some(&fake_ws), 812 + subscribe_timeout: std::time::Duration::from_secs(5), 813 + verbose: false, 814 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 815 + commit_report: false, 816 + force_self_mint: false, 817 + self_mint_curve: SelfMintCurve::Es256k, 818 + report_subject_override: None, 819 + self_mint_signer: None, 820 + pds_credentials: None, 821 + pds_xrpc_client: None, 822 + pds_xrpc_client_override: None, 823 + run_id: "test-run-id", 824 + }; 825 + 826 + let report = run_pipeline(target, opts).await; 827 + let rendered = normalize_timing(render_report_to_string(&report)); 828 + 829 + // The rendered output must show the Advisory status for the mismatch 830 + // check and the local URL as the labeler endpoint. Asserting against 831 + // the rendered string keeps the test independent of LabelerReport 832 + // internals while still catching regressions in either property. 833 + assert!( 834 + rendered.contains("[WARN] resolved DID matches --did flag"), 835 + "expected Advisory on resolved_did_matches_flag, got:\n{rendered}" 836 + ); 837 + assert!( 838 + rendered.contains("Labeler endpoint: http://localhost:8080/"), 839 + "expected header to show the local labeler endpoint override, got:\n{rendered}" 840 + ); 841 + 842 + insta::assert_snapshot!(rendered); 843 + }
+54
tests/snapshots/labeler_identity__local_http_override_mismatch_is_advisory.snap
··· 1 + --- 2 + source: tests/labeler_identity.rs 3 + expression: rendered 4 + --- 5 + Target: http://localhost:8080/ (with explicit DID) 6 + Resolved DID: did:plc:endpoint_mismatch_test_123456789 7 + PDS endpoint: https://pds.example.com/ 8 + Labeler endpoint: http://localhost:8080/ 9 + elapsed: Xms 10 + 11 + == Identity == 12 + [OK] target resolution 13 + [OK] DID document fetch 14 + [OK] labeler service entry 15 + [OK] labeler endpoint URL 16 + [OK] labeler endpoint scheme 17 + [WARN] resolved DID matches --did flag 18 + labeler::identity::resolved_did_matches_flag 19 + 20 + × DID document endpoint (https://original-endpoint.example.com/) does not match local override (http://localhost:8080/); using the local URL for the remaining stages 21 + ╭─[https://plc.directory/did:plc:endpoint_mismatch_test_123456789:13:26] 22 + 12 │ "id": "#atproto_labeler", 23 + 13 │ "serviceEndpoint": "https://original-endpoint.example.com", 24 + · ───────────────────┬─────────────────── 25 + · ╰── endpoint value 26 + 14 │ "type": "AtprotoLabeler" 27 + ╰──── 28 + [OK] signing key entry 29 + [OK] PDS endpoint entry 30 + [OK] labeler record fetch 31 + [OK] labeler record policy list 32 + == HTTP == 33 + [OK] Labeler endpoint reachability 34 + [OK] First page schema 35 + [WARN] Labeler has no published labels 36 + [OK] First page was complete; pagination not exercised 37 + == Subscription == 38 + [WARN] Subscription backfill had no frames — labeler has no published labels 39 + [SKIP] Subscription live-tail skipped — labeler has no published labels 40 + == Crypto == 41 + [SKIP] Crypto stage (no labels to verify) — labeler published no labels; nothing to verify 42 + == Report == 43 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 44 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 45 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 46 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 47 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 48 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 49 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 50 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 51 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 52 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 53 + 54 + Summary: 12 passed, 0 failed (spec), 0 network errors, 3 advisories, 12 skipped. Exit code: 0
+1 -1
tests/snapshots/labeler_identity__non_https_endpoint_renders_spec_violation.snap
··· 13 13 [FAIL] labeler endpoint scheme 14 14 labeler::identity::labeler_endpoint_is_https 15 15 16 - × Labeler endpoint must use HTTPS, got: http://labeler.example.com 16 + × Labeler endpoint must use HTTPS (or HTTP with a local hostname), got: http://labeler.example.com 17 17 ╭─[https://plc.directory/did:plc:non_https_endpoint_test_123456:13:26] 18 18 12 │ "id": "#atproto_labeler", 19 19 13 │ "serviceEndpoint": "http://labeler.example.com",