indigo's identity directory refuses HTTP and IP-hosted did:web, and its
OAuth client is growing an SSRF-guarded transport that refuses loopback
and private addresses. Local development and the test suites need both,
and the workarounds were scattered: two did:web fallbacks in the
resolver, a hand-rolled appview key fetch on the hold, and the OAuth
client left on indigo's defaults so any test driving it against an
httptest server depended on the transport staying permissive.
Move every departure from indigo's defaults into one file pair in
pkg/atproto: indigo_prod.go (!testmode) returns indigo's directory and
OAuth client unchanged; indigo_local.go (testmode) wraps the directory
so a did:web naming an IP, localhost, or a host with a port resolves
over plain HTTP, and gives the OAuth client plain HTTP clients. All six
identity and OAuth constructor call sites go through NewDirectory and
NewOAuthClientApp. The resolver fallbacks, DIDWebToURL, and the hold's
scheme-guessing key fetch are gone; the hold resolves the appview key
through the directory, preferring #appview, and purges and retries once
on a signature failure so a re-keyed appview is not masked by the
24-hour cache.
There is no runtime switch for this: a production binary cannot be
configured to resolve local DIDs. The runtime test_mode flag still
gates the remaining behavioral branches only.
Tests, the harness, make dev, Air, Dockerfile.dev, and docker-compose
build with the tag; fixtures that need loopback did:web fail fast
naming it. Test hold servers now serve a did.json via pkg/testpds so
they resolve as real holds under the tag.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
An unauthenticated token request for a repo on a private hold came back with a
signed token granting pull, and /v2/ then 401ed that exact token. The
authorization server and the resource server disagreed about the same request.
The old behaviour was deliberate — "minting a pull-only token is not a grant",
with the hold owning the decision via captain.Public — but a token spec expects
the server to issue the subset it will authorize, so granting pull and then
refusing it is the wrong shape.
Adds an optional AnonymousAuthorizer, kept separate from Authorizer because the
anonymous path has no DID and no auth method (three of Authorize's four
arguments are meaningless) and because it must drop whole entries rather than
narrow actions in place, where entries can belong to different owners. Denied
entries are dropped; if nothing granting survives, the caller gets the standard
401 challenge rather than a token with an empty access list, so docker prompts
for credentials instead of proceeding to a second 401.
The scope-less /v2/ ping and the actionless entry NarrowToPullOnly preserves on
purpose both bypass the gate entirely — no identity resolution, no hold lookup —
since anonymous discovery depends on them.
Fails open on any lookup error, matching the /v2/ check, which states the
reason: the hold is the enforcing authority and a transient failure must not
break anonymous pulls of public images. /v2/ still enforces; this is a
correctness and UX fix, not a security fix, and nothing was exposed.
Also closes the successor asymmetry documented under finding 3: /v2/ applies a
single-hop migration redirect before checking read access, so judging the
pre-migration identity here would have reintroduced the disagreement this gate
removes. It was one extra local read of hold_captain_records. Tests pin both
directions and prove the chain is not followed past one hop.
Costs one directory-cached identity resolution plus two local SQL reads per
granting entry, and no call to the hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
The quota gate ran on any scope containing "push" and denied the entire
token request, so "quota exceeded ... Delete images to free space" named
a remedy the gate itself blocked: docker and crane both request
pull,push,delete for a manifest delete, and manifest DELETE is
bearer-only, so there was no path left to free space.
When the request also asks for delete, drop push from the repository
entries and issue the reduced token instead of denying. A plain
pull,push is still denied so the quota message reaches the client that
needs to see it; granting a pushless token there would turn a clear
error into an opaque 401 on the first blob upload.
The narrowing happens in place on the access slice the handler hands to
the issuer, so document that on token.Authorizer along with the ordering
the gate goroutine depends on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>