diff --git a/internal/consume/comments.go b/internal/consume/comments.go new file mode 100644 --- /dev/null +++ b/internal/consume/comments.go @@ -0,0 +1,249 @@ +package consume + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "tidepool/internal/errors" + "tidepool/internal/store" +) + +// The native-comment path: a social.coves.community.comment record in a Coves +// user's own repo that belongs to a thread the bridge federates. +// +// Cycle F implements CREATE. Update, delete, the Lemmy depth cap and parents +// that are themselves comments are cycle H; they extend this file rather than +// replacing it, because the four steps below are the same for all of them. + +// handleComment applies one comment commit. +// +// The ORDER of the steps is the design, and each one is a gate on the next: +// +// 1. the opt-out gate, so a user who said no never gets an AP identity +// created for them; +// 2. the thread resolution, so a comment in a NATIVE community — which this +// bridge has no business federating — is skipped before anything is +// written or minted; +// 3. the lazy mint, THROUGH handle verification, because the local part it +// derives is frozen at creation; +// 4. the outbound state, then the intent — state first, because the intent is +// built from it and a delete one day has nothing else to be built from. +func (d *Dispatcher) handleComment(ctx context.Context, did string, commit *CommitEvent) error { + if commit.Operation != operationCreate { + // Update and delete are cycle H. Returning nil rather than an error + // keeps the cursor moving; the rev gate has already claimed this + // revision, which is what a later handler needs to stay ordered. + d.logger.Debug("comment operation not handled yet", + slog.String("operation", commit.Operation), slog.String("did", did)) + return nil + } + + federating, err := d.mayFederate(ctx, did) + if err != nil { + return err + } + if !federating { + // The residual split-thread case, explicitly chosen (decision 11): the + // comment stays on the atproto side and the Lemmy side never sees it. + d.logger.Debug("skipping comment from an opted-out author", + slog.String("did", did), slog.String("rkey", commit.RKey)) + return nil + } + + thread, err := d.resolveThread(ctx, commit) + if err != nil { + return err + } + if thread == nil { + // Most native comments live in native communities. Dead-lettering + // every one of them would bury the queue in events that are working + // exactly as intended. + d.logger.Debug("skipping comment with no federated parent", + slog.String("did", did), slog.String("rkey", commit.RKey)) + return nil + } + + if err := d.ensureActor(ctx, did); err != nil { + return err + } + + atURI := commitRecordURI(did, commit) + snapshot, err := commentSnapshot(atURI, commit, thread) + if err != nil { + return err + } + + stored, err := d.objects.Upsert(ctx, store.OutboundObject{ + ATURI: atURI, + APObjectID: d.apObjectID(did, commit), + LastCID: commit.CID, + LastRev: commit.Rev, + // The community comes from the PARENT's mapping, never from anything + // the comment asserts about itself: a record can claim any community + // it likes, but its parent's mapping is what the bridge already + // federated. + CommunityDID: thread.CommunityDID, + CommunityAPID: thread.CommunityAPID, + TranslatedSnapshot: snapshot, + Depth: thread.Depth + 1, + }) + if err != nil { + return fmt.Errorf("write outbound state for %s: %w", atURI, err) + } + + intent := CommentIntent{ + Op: operationCreate, + ATURI: atURI, + ID: ActivityID(d.userOrigin, atURI, operationCreate, stored.LastActivitySeq), + CommunityAPID: thread.CommunityAPID, + ParentAPID: thread.ParentAPID, + Snapshot: snapshot, + } + // parentATURI carries the causal dependency (decision 15): delivery must + // not present a reply to a peer before the thing it replies to. + if err := d.enqueuer.EnqueueActivity(ctx, did, did, thread.ParentATURI, intent); err != nil { + return fmt.Errorf("enqueue comment intent for %s: %w", atURI, err) + } + return nil +} + +// resolvedThread is everything a comment needs from the thing it replies to. +// All of it comes from state the bridge already holds, never from the record. +type resolvedThread struct { + ParentATURI string + ParentAPID string + CommunityDID string + CommunityAPID string + // Depth is the PARENT's reply depth; the comment sits one below it. + Depth int +} + +// resolveThread resolves the comment's parent through ap_objects and the +// parent's community through communities. A nil thread with a nil error means +// "not federated here" — a skip, not a failure. +func (d *Dispatcher) resolveThread(ctx context.Context, commit *CommitEvent) (*resolvedThread, error) { + parentATURI := replyRef(commit.Record, "parent") + if parentATURI == "" { + // A comment with no reply.parent is a top-level comment shape this + // task does not federate; root-only replies fall back to the root. + parentATURI = replyRef(commit.Record, "root") + } + if parentATURI == "" { + return nil, nil + } + + parent, err := d.objectMappings.GetByATURI(ctx, parentATURI) + if errors.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("resolve comment parent %s: %w", parentATURI, err) + } + if parent.CommunityDID == "" { + return nil, nil + } + + community, err := d.communities.GetByDID(ctx, parent.CommunityDID) + if errors.IsNotFound(err) { + // The parent is mapped but its community is not one this bridge + // federates, so there is nowhere to deliver to. + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("resolve community %s: %w", parent.CommunityDID, err) + } + + thread := &resolvedThread{ + ParentATURI: parentATURI, + ParentAPID: parent.APID, + CommunityDID: parent.CommunityDID, + CommunityAPID: community.APGroupID, + } + // A parent that is itself a federated comment carries its own depth; a + // parent that is a post has none, and its replies are depth 1. + if parentState, err := d.objects.GetByATURI(ctx, parentATURI); err == nil { + thread.Depth = parentState.Depth + } else if !errors.IsNotFound(err) { + return nil, fmt.Errorf("read parent outbound state %s: %w", parentATURI, err) + } + return thread, nil +} + +// ensureActor makes sure the author has an AP identity, resolving their handle +// first if they do not. +// +// The existence check comes FIRST and short-circuits everything: the local +// part is frozen, so re-resolving for an actor that already exists would put +// two network round-trips (PLC + well-known) in front of every comment to +// re-derive a name that can no longer change. +func (d *Dispatcher) ensureActor(ctx context.Context, did string) error { + _, err := d.apActors.GetByDID(ctx, did) + if err == nil { + return nil + } + if !errors.IsNotFound(err) { + return fmt.Errorf("look up actor for %s: %w", did, err) + } + + // No fallback handle exists, by design: minting on a guess would freeze + // the wrong local part, and freezing is not undoable. An unresolvable + // handle FAILS the event so the connector's retry and the redriver can + // recover it, rather than dropping the comment silently. + handle, err := d.resolver.ResolveDIDHandle(ctx, did) + if err != nil { + return fmt.Errorf("resolve handle for %s: %w", did, err) + } + if _, err := d.actors.CreateActorForDID(ctx, did, handle); err != nil { + return fmt.Errorf("mint actor for %s: %w", did, err) + } + return nil +} + +// apObjectID is the AP id a native record federates as. It is derived from the +// at-uri's three parts rather than the rkey alone: rkeys are unique within one +// repo's collection, so an rkey-only id would collide across authors, and this +// URL has to identify exactly one record when a peer fetches it back. +func (d *Dispatcher) apObjectID(did string, commit *CommitEvent) string { + return d.userOrigin + "/ap/object/" + did + "/" + commit.Collection + "/" + commit.RKey +} + +// commentSnapshot is the durable state a Delete or a restore is rebuilt from. +// It keeps the RECORD as it arrived plus the context that was resolved around +// it, because the delete commit that arrives one day carries neither. +// Rendering it into ActivityPub vocabulary is task 15's; this is the input. +func commentSnapshot(atURI string, commit *CommitEvent, thread *resolvedThread) ([]byte, error) { + snapshot, err := json.Marshal(map[string]any{ + "atUri": atURI, + "cid": commit.CID, + "rev": commit.Rev, + "collection": commit.Collection, + "record": commit.Record, + "parentApId": thread.ParentAPID, + "communityApId": thread.CommunityAPID, + }) + if err != nil { + // A record that will not marshal cannot be stored or delivered, and + // retrying serializes exactly the same bytes. + return nil, fmt.Errorf("%w: snapshot %s: %w", ErrPermanentEvent, atURI, err) + } + return snapshot, nil +} + +// replyRef reads reply.{name}.uri out of a decoded comment record. +func replyRef(record map[string]any, name string) string { + reply, ok := record["reply"].(map[string]any) + if !ok { + return "" + } + ref, ok := reply[name].(map[string]any) + if !ok { + return "" + } + uri, _ := ref["uri"].(string) + return uri +} + +// operationCreate is the commit operation that carries a new record. +const operationCreate = "create" diff --git a/internal/consume/dispatch.go b/internal/consume/dispatch.go --- a/internal/consume/dispatch.go +++ b/internal/consume/dispatch.go @@ -117,6 +117,10 @@ Engine AcceptanceEngine // RemoteDeleter is the task 17 destructive seam. Optional (see // RemoteContentDeleter). RemoteDeleter RemoteContentDeleter + // Resolver verifies a DID's handle before the FIRST mint. Required: the + // local part is frozen at creation, so minting without a verified handle + // would freeze a guess. + Resolver DIDResolver // UserOrigin is AP_USER_ORIGIN: the origin every deterministic activity // id is minted under. UserOrigin string @@ -133,12 +137,19 @@ actors ActorMinter enqueuer OutboundEnqueuer engine AcceptanceEngine remoteDeleter RemoteContentDeleter + resolver DIDResolver prefs store.FederationPrefs apActors store.APActors - hosted *hostedRepos - gate *RevGate - userOrigin string - logger *slog.Logger + // objects is the outbound state; objectMappings and communities are the + // bridge's own record of what it already federated, which is where a + // comment's thread and target community are resolved FROM. + objectMappings store.APObjects + objects store.OutboundObjects + communities store.Communities + hosted *hostedRepos + gate *RevGate + userOrigin string + logger *slog.Logger } var _ EventHandler = (*Dispatcher)(nil) @@ -156,6 +167,10 @@ // federated from an identity that does not exist. return nil, errors.NewValidationError("Actors", "must not be nil") case opts.Enqueuer == nil: return nil, errors.NewValidationError("Enqueuer", "must not be nil") + case opts.Resolver == nil: + // Minting without a verified handle would freeze a guessed local part + // forever, so there is no safe default to fall back to. + return nil, errors.NewValidationError("Resolver", "must not be nil") case opts.UserOrigin == "": // Every outbound activity id is minted under this origin, and the id // is a wire contract: deriving one under "" would publish ids no peer @@ -168,17 +183,21 @@ if logger == nil { logger = slog.Default() } return &Dispatcher{ - db: opts.DB, - actors: opts.Actors, - enqueuer: opts.Enqueuer, - engine: opts.Engine, - remoteDeleter: opts.RemoteDeleter, - prefs: store.NewFederationPrefs(opts.DB), - apActors: store.NewAPActors(opts.DB), - hosted: newHostedRepos(opts.DB), - gate: NewRevGate(opts.DB), - userOrigin: opts.UserOrigin, - logger: logger, + db: opts.DB, + actors: opts.Actors, + enqueuer: opts.Enqueuer, + engine: opts.Engine, + remoteDeleter: opts.RemoteDeleter, + resolver: opts.Resolver, + prefs: store.NewFederationPrefs(opts.DB), + apActors: store.NewAPActors(opts.DB), + objectMappings: store.NewAPObjects(opts.DB), + objects: store.NewOutboundObjects(opts.DB), + communities: store.NewCommunities(opts.DB), + hosted: newHostedRepos(opts.DB), + gate: NewRevGate(opts.DB), + userOrigin: opts.UserOrigin, + logger: logger, }, nil } @@ -225,6 +244,8 @@ case CollectionPostV2: return d.handlePostV2 case CollectionComment: return d.handleComment + case CollectionProfile: + return d.handleProfile } return nil } @@ -328,20 +349,6 @@ } // accountStatusDeleted is the ONLY #account status that means deletion. const accountStatusDeleted = "deleted" - -// handleIdentity applies a #identity handle change. The local part is frozen -// at actor creation, so a rename may refresh the profile CACHE and nothing -// else — re-deriving the local part would strand every federated mention of -// the old name. The cache refresh needs the DID re-resolved (identity events -// can be stale) and lands with the profile handler. -func (d *Dispatcher) handleIdentity(_ context.Context, event *JetstreamEvent) error { - if event.Identity == nil { - return fmt.Errorf("%w: identity event for %s carries no identity", ErrPermanentEvent, event.DID) - } - d.logger.Debug("identity event observed; the local part is never re-derived", - slog.String("did", event.Identity.DID), slog.String("handle", event.Identity.Handle)) - return nil -} // activityIDVersionTag prefixes every activity-id preimage. It exists so the // derivation can CHANGE without colliding with ids already published: bump the diff --git a/internal/consume/dispatch_test.go b/internal/consume/dispatch_test.go --- a/internal/consume/dispatch_test.go +++ b/internal/consume/dispatch_test.go @@ -5,6 +5,7 @@ "context" "database/sql" "encoding/json" "fmt" + "strings" "sync" "testing" @@ -38,19 +39,51 @@ // the real service keeps these cycles independent of the handle resolver // (cycle F) while still pinning WHETHER a mint was attempted, which is the // ordering question the opt-out gate is about. type recordingMinter struct { + // db makes the double behave like the real service in the one way that + // matters here: it actually CREATES the ap_actors row, so a second event + // for the same DID finds an existing actor. Without that, "resolve only + // before the FIRST mint" could not be observed through this seam. + db *sql.DB + mu sync.Mutex calls []struct{ DID, Handle string } err error } -func (m *recordingMinter) CreateActorForDID(_ context.Context, did, handle string) (*store.APActor, error) { +func (m *recordingMinter) CreateActorForDID(ctx context.Context, did, handle string) (*store.APActor, error) { m.mu.Lock() defer m.mu.Unlock() m.calls = append(m.calls, struct{ DID, Handle string }{did, handle}) if m.err != nil { return nil, m.err } - return &store.APActor{DID: did, Kind: store.ActorTypePerson, LocalPart: "minted"}, nil + localPart := "minted" + if handle != "" { + localPart, _, _ = strings.Cut(handle, ".") + } + if m.db != nil { + // Get-or-create, like the real service. + _, err := m.db.ExecContext(ctx, ` + INSERT INTO ap_actors (did, kind, actor_id, normalized_origin, local_part, + rsa_key_sealed, rsa_key_version, public_key_pem) + VALUES ($1, 'person', $2, 'coves.social', $3, '\x00'::bytea, 1, 'pem') + ON CONFLICT (did) DO NOTHING`, + did, acceptUserOrigin+"/ap/actor/"+did, localPart) + if err != nil { + return nil, err + } + } + return &store.APActor{DID: did, Kind: store.ActorTypePerson, LocalPart: localPart}, nil +} + +func (m *recordingMinter) Handles() []string { + m.mu.Lock() + defer m.mu.Unlock() + handles := []string{} + for _, call := range m.calls { + handles = append(handles, call.Handle) + } + return handles } func (m *recordingMinter) DIDs() []string { @@ -126,16 +159,18 @@ minter *recordingMinter enqueuer *recordingEnqueuer engine *recordingEngine deleter *recordingDeleter + resolver *recordingResolver } func newDispatchFixture(t *testing.T, database *sql.DB, mutate ...func(*Options)) *dispatchFixture { t.Helper() fixture := &dispatchFixture{ db: database, - minter: &recordingMinter{}, + minter: &recordingMinter{db: database}, enqueuer: &recordingEnqueuer{}, engine: &recordingEngine{}, deleter: &recordingDeleter{}, + resolver: &recordingResolver{handle: dispatchNativeHandle}, } opts := Options{ DB: database, @@ -143,6 +178,7 @@ Actors: fixture.minter, Enqueuer: fixture.enqueuer, Engine: fixture.engine, RemoteDeleter: fixture.deleter, + Resolver: fixture.resolver, UserOrigin: acceptUserOrigin, } for _, m := range mutate { @@ -177,8 +213,11 @@ // --------------------------------------------------------------------------- const ( dispatchNativeDID = "did:plc:7iza6de2dwap2sbkpav7c6c6" - dispatchRev = "3lzrev0000001" - dispatchRevHigher = "3lzrev0000002" + // dispatchNativeHandle is what the resolver verifies for that DID; the + // local part derives from its first label. + dispatchNativeHandle = "nativeuser.coves.social" + dispatchRev = "3lzrev0000001" + dispatchRevHigher = "3lzrev0000002" ) // federationFrame is the opt-out record (literal:self). diff --git a/internal/consume/federation.go b/internal/consume/federation.go --- a/internal/consume/federation.go +++ b/internal/consume/federation.go @@ -133,38 +133,6 @@ } return pref.Enabled, nil } -// handleComment applies a native comment commit. -// -// What this cycle owns is the ORDER: the opt-out gate runs BEFORE the lazy -// mint, so a user who opted out before ever federating anything never gets an -// AP identity created for them. Resolving the thread through ap_objects, -// writing outbound_objects state and enqueueing the intent are the comment -// handler proper (cycle H); they run after these two steps, not instead of -// them. -func (d *Dispatcher) handleComment(ctx context.Context, did string, commit *CommitEvent) error { - federating, err := d.mayFederate(ctx, did) - if err != nil { - return err - } - if !federating { - // The residual split-thread case, explicitly chosen (decision 11): the - // comment stays on the atproto side and the Lemmy side never sees it. - d.logger.Debug("skipping comment from an opted-out author", - slog.String("did", did), slog.String("rkey", commit.RKey)) - return nil - } - - // The lazy mint: this interaction is what earns the identity. The handle - // is not in the commit — Jetstream commits carry none — and it is NOT - // synthesized here, because the local part is frozen at creation and a - // placeholder would freeze the wrong name forever. It arrives with the DID - // resolution the profile/identity handler brings. - if _, err := d.actors.CreateActorForDID(ctx, did, ""); err != nil { - return fmt.Errorf("mint actor for commenter %s: %w", did, err) - } - return nil -} - // operationDelete is the commit operation that carries no record body. const operationDelete = "delete" diff --git a/internal/consume/identity_fake_test.go b/internal/consume/identity_fake_test.go new file mode 100644 --- /dev/null +++ b/internal/consume/identity_fake_test.go @@ -0,0 +1,254 @@ +package consume + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// fakeIdentity is the atproto identity world a resolver test runs against: a +// PLC directory serving DID documents, and the handles' own +// /.well-known/atproto-did endpoints serving the reverse claim. Both live on +// ONE httptest listener, dispatched by request Host, so a single fake can +// stage every disagreement between the two directions. +// +// Local-only: identityRewriteTransport refuses any host the fake does not +// know, so a resolver bug cannot turn into a request to the real internet. +type fakeIdentity struct { + server *httptest.Server + + mu sync.Mutex + // alsoKnownAs is the handle each DID's document CLAIMS. + alsoKnownAs map[string]string + // wellKnown is the DID each handle claims BACK. A handle absent here has + // no well-known endpoint (404) — the two maps are separate precisely so a + // test can make the directions disagree. + wellKnown map[string]string + // plcStatus / plcBody force a directory response for one DID. + plcStatus map[string]int + plcBody map[string]string + // wellKnownStatus forces a status for one handle's endpoint. + wellKnownStatus map[string]int + + plcHits int + wellKnownHits int +} + +const wellKnownATProtoDIDPath = "/.well-known/atproto-did" + +func newFakeIdentity(t *testing.T) *fakeIdentity { + t.Helper() + + fake := &fakeIdentity{ + alsoKnownAs: map[string]string{}, + wellKnown: map[string]string{}, + plcStatus: map[string]int{}, + plcBody: map[string]string{}, + wellKnownStatus: map[string]int{}, + } + + mux := http.NewServeMux() + mux.HandleFunc(wellKnownATProtoDIDPath, func(w http.ResponseWriter, r *http.Request) { + handle := strings.ToLower(hostOnly(r.Host)) + + fake.mu.Lock() + fake.wellKnownHits++ + status, forced := fake.wellKnownStatus[handle] + did, claimed := fake.wellKnown[handle] + fake.mu.Unlock() + + if forced { + w.WriteHeader(status) + _, _ = w.Write([]byte("forced well-known failure")) + return + } + if !claimed { + // A handle with no well-known endpoint. In the real world this is + // often a DNS-TXT-only handle; here it is simply unverifiable. + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain") + // Real PDSes serve the DID with a trailing newline; a resolver that + // compares without trimming would reject every genuine handle. + _, _ = w.Write([]byte(did + "\n")) + }) + + // Everything else is the PLC directory: GET /{did}. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + did := strings.TrimPrefix(r.URL.Path, "/") + + fake.mu.Lock() + fake.plcHits++ + status, forcedStatus := fake.plcStatus[did] + body, forcedBody := fake.plcBody[did] + handle, known := fake.alsoKnownAs[did] + fake.mu.Unlock() + + if forcedStatus { + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"message":"forced directory failure"}`)) + return + } + w.Header().Set("Content-Type", "application/did+ld+json") + if forcedBody { + _, _ = w.Write([]byte(body)) + return + } + if !known { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(fmt.Sprintf(`{ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": %q, + "alsoKnownAs": ["at://%s"], + "verificationMethod": [], + "service": [{"id":"#atproto_pds","type":"AtprotoPersonalDataServer", + "serviceEndpoint":"https://pds.example"}] + }`, did, handle))) + }) + + fake.server = httptest.NewServer(mux) + t.Cleanup(fake.server.Close) + return fake +} + +// claim wires BOTH directions: the DID document names the handle and the +// handle names the DID back. This is the only combination a resolver may +// accept. +func (f *fakeIdentity) claim(did, handle string) { + f.mu.Lock() + defer f.mu.Unlock() + f.alsoKnownAs[did] = handle + f.wellKnown[strings.ToLower(handle)] = did +} + +// claimOneWay gives the DID document a handle that does NOT claim it back — +// the handle has no well-known endpoint at all. +func (f *fakeIdentity) claimOneWay(did, handle string) { + f.mu.Lock() + defer f.mu.Unlock() + f.alsoKnownAs[did] = handle +} + +// wellKnownReturns overrides who a handle says it belongs to. Pointing an +// already-claimed handle at a different DID is the impersonation case. +func (f *fakeIdentity) wellKnownReturns(handle, did string) { + f.mu.Lock() + defer f.mu.Unlock() + f.wellKnown[strings.ToLower(handle)] = did +} + +func (f *fakeIdentity) plcFails(did string, status int) { + f.mu.Lock() + defer f.mu.Unlock() + f.plcStatus[did] = status +} + +func (f *fakeIdentity) plcServes(did, body string) { + f.mu.Lock() + defer f.mu.Unlock() + f.plcBody[did] = body +} + +func (f *fakeIdentity) wellKnownFails(handle string, status int) { + f.mu.Lock() + defer f.mu.Unlock() + f.wellKnownStatus[strings.ToLower(handle)] = status +} + +func (f *fakeIdentity) PLCHits() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.plcHits +} + +func (f *fakeIdentity) WellKnownHits() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.wellKnownHits +} + +// resolver builds the REAL HandleResolver against this fake. Production wires +// ap.NewGuardedHTTPClient here; the test transport below plays the same role +// the ALLOW_PRIVATE_FETCH relaxation does in dev, except that it also refuses +// every host the fake does not serve. +func (f *fakeIdentity) resolver(t *testing.T) *HandleResolver { + t.Helper() + resolver, err := NewHandleResolver(ResolverOptions{ + PLCDirectoryURL: f.server.URL, + HTTPClient: &http.Client{ + Transport: identityRewriteTransport{target: f.server.Listener.Addr().String()}, + }, + UserAgent: "tidepool-test/0.1", + }) + require.NoError(t, err, "build handle resolver") + require.NotNil(t, resolver) + return resolver +} + +// identityRewriteTransport sends requests for handle hosts to the fake's +// listener while preserving the request URL and Host, so the resolver believes +// it is talking to https://alice.coves.social. Anything outside the test +// namespace is refused: these tests never touch the network. +type identityRewriteTransport struct { + target string +} + +func (rt identityRewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + host := hostOnly(req.URL.Host) + allowed := host == hostOnly(rt.target) || + strings.HasSuffix(host, ".coves.social") || + strings.HasSuffix(host, ".example") || + host == "127.0.0.1" || host == "localhost" + if !allowed { + return nil, fmt.Errorf("refusing outbound request to %s: tests may not reach the network", req.URL) + } + clone := req.Clone(req.Context()) + clone.Host = req.URL.Host // the mux dispatches the well-known by Host + clone.URL.Scheme = "http" + clone.URL.Host = rt.target + return http.DefaultTransport.RoundTrip(clone) +} + +func hostOnly(hostport string) string { + if idx := strings.LastIndex(hostport, ":"); idx != -1 { + if !strings.Contains(hostport[idx+1:], "]") { + return hostport[:idx] + } + } + return hostport +} + +// recordingResolver is the DIDResolver seam, recorded. Dispatcher-tier tests +// use it so they pin WHETHER resolution happened and WHAT handle reached the +// mint, without standing up the identity world for every case. +type recordingResolver struct { + mu sync.Mutex + calls []string + handle string + err error +} + +func (r *recordingResolver) ResolveDIDHandle(_ context.Context, did string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, did) + if r.err != nil { + return "", r.err + } + return r.handle, nil +} + +func (r *recordingResolver) Calls() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.calls...) +} diff --git a/internal/consume/mint_test.go b/internal/consume/mint_test.go new file mode 100644 --- /dev/null +++ b/internal/consume/mint_test.go @@ -0,0 +1,178 @@ +package consume + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/store" +) + +// Task 14 cycle F2: the mint runs THROUGH resolution, and the comment create +// path completes. +// +// SCOPE PULLED FROM CYCLE H: finishing the outer test needs the comment +// create path end to end (resolve the thread through ap_objects → write +// outbound_objects → enqueue one intent), so it is pinned here. Cycle H still +// owns update, delete, the depth cap, and resolving a parent that is itself a +// comment or a Lemmy object. + +func TestCommentMint_UsesTheVerifiedHandle(t *testing.T) { + database := dispatchTestDB(t) + seedBridgedCommunity(t, database) + seedThreadRoot(t, database) + + fixture := newDispatchFixture(t, database) + fixture.resolver.handle = "alice.coves.social" + + require.NoError(t, fixture.handle(t, + commentFrameFor(dispatchNativeDID, dispatchRev, "3lzcmnt7777ff"))) + + assert.Equal(t, []string{dispatchNativeDID}, fixture.resolver.Calls(), + "the first federating interaction resolves the author's handle") + assert.Equal(t, []string{"alice.coves.social"}, fixture.minter.Handles(), + "the VERIFIED handle is what reaches CreateActorForDID — the commit carries "+ + "none, and the local part it derives is frozen forever") +} + +func TestCommentMint_ResolvesOnlyBeforeTheFirstMint(t *testing.T) { + database := dispatchTestDB(t) + seedBridgedCommunity(t, database) + seedThreadRoot(t, database) + + fixture := newDispatchFixture(t, database) + + require.NoError(t, fixture.handle(t, + commentFrameFor(dispatchNativeDID, dispatchRev, "3lzcmnt8888gg"))) + require.Len(t, fixture.resolver.Calls(), 1) + + // A second comment from the same author, now that the actor exists. + require.NoError(t, fixture.handle(t, + commentFrameFor(dispatchNativeDID, dispatchRevHigher, "3lzcmnt9999hh"))) + + assert.Len(t, fixture.resolver.Calls(), 1, + "an existing actor short-circuits resolution: the local part is already frozen, "+ + "so re-resolving would put two network round-trips (PLC + well-known) in "+ + "front of EVERY comment for a name that can no longer change") + assert.Len(t, fixture.minter.Handles(), 1, + "and the mint is not re-attempted either") +} + +func TestCommentMint_ResolverFailureWritesNothing(t *testing.T) { + database := dispatchTestDB(t) + seedBridgedCommunity(t, database) + seedThreadRoot(t, database) + + fixture := newDispatchFixture(t, database) + fixture.resolver.err = fmt.Errorf("plc directory unreachable") + + err := fixture.handle(t, commentFrameFor(dispatchNativeDID, dispatchRev, "3lzcmntaaaaii")) + + require.Error(t, err, + "an unresolvable handle must FAIL the event, not skip it: skipping would drop "+ + "the comment silently, and the connector's retry/DLQ is the recovery path") + assert.NotErrorIs(t, err, ErrPermanentEvent, + "a directory outage is transient, so the redriver gets to replay it") + + assert.Empty(t, fixture.minter.Handles(), + "NO MINT until the handle is verified: minting on a fallback would freeze the "+ + "wrong local part, and freezing is not undoable") + assert.Zero(t, countRows(t, database, "ap_actors")) + assert.Zero(t, countRows(t, database, "outbound_objects"), + "and no outbound state is written for an actor that does not exist") + assert.Empty(t, fixture.enqueuer.Calls(), "and nothing reaches task 15") +} + +func TestCommentMint_ResolverFailureLeavesTheGateUnadvanced(t *testing.T) { + database := dispatchTestDB(t) + seedBridgedCommunity(t, database) + seedThreadRoot(t, database) + + fixture := newDispatchFixture(t, database) + fixture.resolver.err = fmt.Errorf("plc directory unreachable") + + require.Error(t, fixture.handle(t, + commentFrameFor(dispatchNativeDID, dispatchRev, "3lzcmntbbbbjj"))) + + assert.Zero(t, countRows(t, database, "jetstream_record_revs"), + "the failed event must not leave a gate row behind: the redrive replays the "+ + "SAME rev, and a claimed gate would reject the retry that was supposed to "+ + "be the recovery") +} + +func TestCommentCreate_WritesOutboundStateAndEnqueuesOneIntent(t *testing.T) { + database := dispatchTestDB(t) + seedBridgedCommunity(t, database) + seedThreadRoot(t, database) + + fixture := newDispatchFixture(t, database) + const rkey = "3lzcmntccccKK" + commentATURI := "at://" + dispatchNativeDID + "/" + CollectionComment + "/" + rkey + + require.NoError(t, fixture.handle(t, commentFrameFor(dispatchNativeDID, dispatchRev, rkey))) + + stored, err := store.NewOutboundObjects(database).GetByATURI(context.Background(), commentATURI) + require.NoError(t, err, + "the create must leave state behind: the DELETE commit that follows it one day "+ + "carries no record body and no CID, so this row is the only thing a "+ + "Delete{Note} can be built from") + require.NotNil(t, stored) + + assert.Equal(t, acceptCommunityDID, stored.CommunityDID, + "the community comes from the thread root's ap_objects mapping, not from "+ + "anything the comment asserts about itself") + assert.Equal(t, acceptCommunityAPID, stored.CommunityAPID) + assert.Equal(t, dispatchRev, stored.LastRev, "provenance: the rev that was applied") + assert.Equal(t, 0, stored.LastActivitySeq, "a create is activity 0") + assert.Equal(t, 1, stored.Depth, + "a direct reply to the thread root is depth 1 (Lemmy caps comments at 50; the "+ + "cap itself is cycle H)") + assert.False(t, stored.IsTombstoned()) + + assert.True(t, strings.HasPrefix(stored.APObjectID, acceptUserOrigin+"/"), + "the AP id must live on the user origin — it is a URL this bridge has to be "+ + "able to serve. Its PATH is deliberately unpinned; task 15 owns AP "+ + "vocabulary. Got %q", stored.APObjectID) + assert.Contains(t, string(stored.TranslatedSnapshot), "hi", + "the snapshot carries the comment's content, because task 17 restores the "+ + "object from it after a tombstone") + + calls := fixture.enqueuer.Calls() + require.Len(t, calls, 1, "exactly one intent per create") + call := calls[0] + assert.Equal(t, dispatchNativeDID, call.ActorDID) + assert.Equal(t, acceptRootATURI, call.ParentATURI, + "parentATURI carries the causal dependency: the reply must not be delivered "+ + "before the thing it replies to") + + intent, ok := call.Intent.(CommentIntent) + require.True(t, ok, "want CommentIntent, got %T", call.Intent) + assert.Equal(t, "create", intent.Op) + assert.Equal(t, commentATURI, intent.ATURI) + assert.Equal(t, acceptCommunityAPID, intent.CommunityAPID) + assert.Equal(t, acceptRootAPID, intent.ParentAPID, + "the parent's AP id is resolved through ap_objects so task 15 need not look it up") + assert.Equal(t, ActivityID(acceptUserOrigin, commentATURI, "create", 0), intent.ActivityID()) +} + +func TestCommentCreate_UnresolvableParentIsSkippedNotFailed(t *testing.T) { + database := dispatchTestDB(t) + seedBridgedCommunity(t, database) + // No thread root seeded: a native thread in a NATIVE community, which this + // bridge has no business federating. + + fixture := newDispatchFixture(t, database) + require.NoError(t, fixture.handle(t, + commentFrameFor(dispatchNativeDID, dispatchRev, "3lzcmntddddll")), + "an unresolvable parent is a SKIP at debug, not an error: most native comments "+ + "live in native communities, and dead-lettering them all would bury the queue") + + assert.Empty(t, fixture.enqueuer.Calls()) + assert.Zero(t, countRows(t, database, "outbound_objects")) + assert.Empty(t, fixture.minter.Handles(), + "and no identity is minted for a comment that was never going to federate") +} diff --git a/internal/consume/outer_acceptance_test.go b/internal/consume/outer_acceptance_test.go --- a/internal/consume/outer_acceptance_test.go +++ b/internal/consume/outer_acceptance_test.go @@ -37,12 +37,17 @@ // The commenter. This DID has NO row anywhere: no ap_actors, no // bridged_actors, no communities, no repo_state. Its actor must be minted // by the act of commenting. - acceptCommenterDID = "did:plc:7iza6de2dwap2sbkpav7c6c6" - acceptCommentRKey = "3lzcmnt3333bb" - acceptCommentCID = "bafyreievgu2ty7qbiaaom5zhmkznsnajuzideek3lo7e65dwqlrvrxnmo4" - acceptCommentRev = "3lzcmntrev001" - acceptCommentATURI = "at://" + acceptCommenterDID + "/social.coves.community.comment/" + acceptCommentRKey - acceptCommentTimeUS = int64(1_775_000_000_000_000) + acceptCommenterDID = "did:plc:7iza6de2dwap2sbkpav7c6c6" + // The commenter's handle, which the bridge has to discover and VERIFY on + // its own: the commit carries no handle, and the local part it derives is + // frozen at creation. + acceptCommenterHandle = "alice.coves.social" + acceptCommenterLocalPart = "alice" + acceptCommentRKey = "3lzcmnt3333bb" + acceptCommentCID = "bafyreievgu2ty7qbiaaom5zhmkznsnajuzideek3lo7e65dwqlrvrxnmo4" + acceptCommentRev = "3lzcmntrev001" + acceptCommentATURI = "at://" + acceptCommenterDID + "/social.coves.community.comment/" + acceptCommentRKey + acceptCommentTimeUS = int64(1_775_000_000_000_000) ) // acceptKEK seals minted actors' AP RSA keys (32 bytes, AES-256). @@ -89,18 +94,24 @@ minter := newPersonasService(t, conn) state := NewPostgresStateStore(conn, CursorSchemaVersion) objects := store.NewOutboundObjects(conn) + // The atproto identity world: a PLC directory serving the commenter's DID + // document, and the handle's own well-known claiming the DID back. Both on + // httptest — no test ever reaches the network. + identity := newFakeIdentity(t) + identity.claim(acceptCommenterDID, acceptCommenterHandle) + resolver := identity.resolver(t) + // ------------------------------------------------------------------- // Run 1: first sighting. // ------------------------------------------------------------------- firstEnqueuer := &recordingEnqueuer{} - runConnector(t, conn, minter, state, firstEnqueuer, acceptCommentTimeUS, + runConnector(t, conn, minter, resolver, state, firstEnqueuer, acceptCommentTimeUS, commentCreateFrame(acceptCommentTimeUS, acceptCommentRev)) - // 1. Lazy mint. The local part's derivation is deliberately NOT asserted - // here: a commit event carries no handle, and where the handle comes - // from is an open design question (see the task report). What the - // bridge MUST NOT do is federate a comment from an identity that - // doesn't exist. + // 1. Lazy mint, through resolution. The commit carries no handle, so the + // bridge had to fetch the DID document, read the handle it claims, and + // confirm the handle claims the DID back — before minting, because the + // local part is frozen at creation. var mintedActors int require.NoError(t, conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM ap_actors WHERE did = $1`, acceptCommenterDID).Scan(&mintedActors)) @@ -108,6 +119,18 @@ require.Equal(t, 1, mintedActors, "the commenter's first federating interaction must lazily mint exactly one AP actor "+ "for %s (task 13's get-or-create)", acceptCommenterDID) + var mintedLocalPart string + require.NoError(t, conn.QueryRowContext(ctx, + `SELECT local_part FROM ap_actors WHERE did = $1`, acceptCommenterDID).Scan(&mintedLocalPart)) + require.Equal(t, acceptCommenterLocalPart, mintedLocalPart, + "the frozen local part derives from the VERIFIED handle %q — this is the whole "+ + "reason the consumer resolves before minting", acceptCommenterHandle) + + require.Positive(t, identity.PLCHits(), "the DID document was fetched") + require.Positive(t, identity.WellKnownHits(), + "and the handle was asked to claim the DID back: one-way trust would let any "+ + "DID freeze somebody else's name") + // 2. Durable outbound state, keyed by the comment's at-uri. stored, err := objects.GetByATURI(ctx, acceptCommentATURI) require.NoError(t, err, @@ -176,7 +199,7 @@ _, err = conn.ExecContext(ctx, `DELETE FROM consumer_cursors`) require.NoError(t, err) replayEnqueuer := &recordingEnqueuer{} - runConnector(t, conn, minter, state, replayEnqueuer, acceptCommentTimeUS, + runConnector(t, conn, minter, resolver, state, replayEnqueuer, acceptCommentTimeUS, commentCreateFrame(acceptCommentTimeUS, acceptCommentRev)) assert.Empty(t, replayEnqueuer.Calls(), @@ -228,6 +251,7 @@ func runConnector( t *testing.T, database *sql.DB, minter ActorMinter, + resolver DIDResolver, state *PostgresStateStore, enqueuer OutboundEnqueuer, lastEventTimeUS int64, @@ -240,6 +264,7 @@ dispatcher, err := NewDispatcher(Options{ DB: database, Actors: minter, + Resolver: resolver, Enqueuer: enqueuer, UserOrigin: acceptUserOrigin, }) diff --git a/internal/consume/profile.go b/internal/consume/profile.go new file mode 100644 --- /dev/null +++ b/internal/consume/profile.go @@ -0,0 +1,126 @@ +package consume + +import ( + "context" + "fmt" + "log/slog" + + "tidepool/internal/errors" + "tidepool/internal/store" +) + +// The profile cache and the #identity handler. +// +// Neither enqueues anything. Lemmy has no Update{Person} handler — verified +// against 0.19.20 and 1.0 main, it 400s — so an outbound profile activity +// would be a guaranteed-failed delivery. Peers refresh through their own lazy +// ≤24h actor refetch, which means this cache only has to be right when it is +// READ. + +// handleProfile refreshes the ap_actors profile CACHE from a +// social.coves.actor.profile record. +// +// A DID with no actor row is skipped rather than minted: a profile edit is not +// a federating interaction, and minting here would give an AP identity to +// every Coves user who ever set a display name. +func (d *Dispatcher) handleProfile(ctx context.Context, did string, commit *CommitEvent) error { + if _, err := d.apActors.GetByDID(ctx, did); err != nil { + if errors.IsNotFound(err) { + d.logger.Debug("profile record for a DID with no actor", slog.String("did", did)) + return nil + } + return fmt.Errorf("look up actor for %s: %w", did, err) + } + + // Deleting the profile record clears the cache; the ACTOR survives, + // because a deleted profile record is not a deleted identity and the local + // part is frozen regardless. + profile := store.APActorProfile{} + if commit.Operation != operationDelete { + profile.DisplayName = stringField(commit.Record, "displayName") + // The lexicon's description IS the AP summary — same thing, two + // vocabularies. + profile.Summary = stringField(commit.Record, "description") + // avatar is deliberately NOT cached. It is a blob REF (a CID), not a + // URL, and turning it into one needs the author's PDS host plus a + // getBlob convention this task has not established; a half-derived URL + // would serve every peer a broken image. + } + + // The record is a whole DOCUMENT, not a patch: every field is written, + // so an omitted one clears the cached value the user removed. Merging + // would keep a bio its owner deleted. + if err := d.apActors.UpdateProfile(ctx, did, profile); err != nil { + if errors.IsNotFound(err) { + return nil // the actor vanished between the check and the write + } + return fmt.Errorf("refresh profile cache for %s: %w", did, err) + } + return nil +} + +// handleIdentity applies a #identity handle change. +// +// The event's own handle is a HINT about which DID to re-check, never an +// answer: identity events can be stale or replayed, so the handle is +// re-resolved and re-verified from scratch. +// +// The local part is untouched. It was frozen at actor creation, and +// re-deriving it would strand every federated mention of the old name — a +// rename may refresh the profile CACHE and nothing else. +func (d *Dispatcher) handleIdentity(ctx context.Context, event *JetstreamEvent) error { + if event.Identity == nil { + return fmt.Errorf("%w: identity event for %s carries no identity", ErrPermanentEvent, event.DID) + } + did := event.Identity.DID + if did == "" { + did = event.DID + } + + // The actor check comes FIRST: every Coves user who renames emits one of + // these, and resolving would spend two network round-trips updating a + // cache that does not exist. + actor, err := d.apActors.GetByDID(ctx, did) + if err != nil { + if errors.IsNotFound(err) { + d.logger.Debug("identity event for a DID with no actor", slog.String("did", did)) + return nil + } + return fmt.Errorf("look up actor for %s: %w", did, err) + } + + handle, err := d.resolver.ResolveDIDHandle(ctx, did) + if err != nil { + // The cache keeps its last VERIFIED value. Filling it with the frame's + // unverified handle would publish a name nobody confirmed, and + // clearing it would lose a good value over a directory outage. + return fmt.Errorf("resolve handle for %s: %w", did, err) + } + + if actor.DisplayName != "" { + // A display name the user SET wins over their handle: the profile + // record is the user speaking about themselves, while the handle is + // only what the cache falls back to when they have not. + d.logger.Debug("handle change leaves a user-set display name alone", + slog.String("did", did), slog.String("handle", handle)) + return nil + } + + if err := d.apActors.UpdateProfile(ctx, did, store.APActorProfile{ + DisplayName: handle, + Summary: actor.Summary, + AvatarURL: actor.AvatarURL, + }); err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("refresh profile cache for %s: %w", did, err) + } + return nil +} + +// stringField reads an optional string from a decoded record. +func stringField(record map[string]any, name string) string { + value, _ := record[name].(string) + return value +} diff --git a/internal/consume/profile_test.go b/internal/consume/profile_test.go new file mode 100644 --- /dev/null +++ b/internal/consume/profile_test.go @@ -0,0 +1,209 @@ +package consume + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Task 14 cycles F3/F4: the profile cache and the #identity handler. +// +// Neither enqueues anything. Lemmy has no Update{Person} handler — verified +// against 0.19.20 and 1.0 main, it 400s — so an outbound profile activity +// would be a guaranteed-failed delivery. Peers refresh through their own lazy +// ≤24h actor refetch, which means this cache only has to be right when it is +// READ. + +func profileFrame(did, rev, operation string, fields string) []byte { + record := "" + if operation != "delete" { + record = fmt.Sprintf(`,"cid":"bafyreievgu2ty7qbiaaom5zhmkznsnajuzideek3lo7e65dwqlrvrxnmo4",`+ + `"record":{"$type":"social.coves.actor.profile"%s}`, fields) + } + return []byte(fmt.Sprintf( + `{"did":%q,"time_us":7100,"kind":"commit","commit":{"rev":%q,"operation":%q,`+ + `"collection":"social.coves.actor.profile","rkey":"self"%s}}`, + did, rev, operation, record)) +} + +func profileCache(t *testing.T, database *sql.DB, did string) (displayName, summary, avatarURL string) { + t.Helper() + require.NoError(t, database.QueryRowContext(context.Background(), + `SELECT display_name, summary, avatar_url FROM ap_actors WHERE did = $1`, did). + Scan(&displayName, &summary, &avatarURL)) + return displayName, summary, avatarURL +} + +// --------------------------------------------------------------------------- +// F3 — actor.profile +// --------------------------------------------------------------------------- + +func TestProfileHandler_RefreshesTheCache(t *testing.T) { + database := dispatchTestDB(t) + seedAPActor(t, database, dispatchNativeDID, "alice") + fixture := newDispatchFixture(t, database) + + // displayName and description are the lexicon's names; description maps to + // ap_actors.summary, which is the AP vocabulary for the same thing. + require.NoError(t, fixture.handle(t, profileFrame(dispatchNativeDID, dispatchRev, "create", + `,"displayName":"Alice Anderson","description":"posts about boats"`))) + + displayName, summary, _ := profileCache(t, database, dispatchNativeDID) + assert.Equal(t, "Alice Anderson", displayName) + assert.Equal(t, "posts about boats", summary, + "the lexicon's description IS the AP summary") + + assert.Empty(t, fixture.enqueuer.Calls(), + "NO outbound activity: Lemmy has no Update{Person} handler, so an enqueue here "+ + "would be a guaranteed 400") +} + +func TestProfileHandler_UpdateOverwritesAndOmissionsClear(t *testing.T) { + database := dispatchTestDB(t) + seedAPActor(t, database, dispatchNativeDID, "alice") + fixture := newDispatchFixture(t, database) + + require.NoError(t, fixture.handle(t, profileFrame(dispatchNativeDID, dispatchRev, "create", + `,"displayName":"Alice Anderson","description":"posts about boats"`))) + + // A record is a whole document: the user removed their bio. + require.NoError(t, fixture.handle(t, profileFrame(dispatchNativeDID, dispatchRevHigher, "update", + `,"displayName":"Alice A."`))) + + displayName, summary, _ := profileCache(t, database, dispatchNativeDID) + assert.Equal(t, "Alice A.", displayName) + assert.Empty(t, summary, + "an omitted field means the user REMOVED it — the record is the whole profile, "+ + "not a patch, so a merge would keep a bio the user deleted") +} + +func TestProfileHandler_AvatarBlobIsNotCachedYet(t *testing.T) { + database := dispatchTestDB(t) + seedAPActor(t, database, dispatchNativeDID, "alice") + fixture := newDispatchFixture(t, database) + + // avatar is a BLOB ref in atproto: {$type, ref:{$link:cid}, mimeType, size}. + require.NoError(t, fixture.handle(t, profileFrame(dispatchNativeDID, dispatchRev, "create", + `,"displayName":"Alice","avatar":{"$type":"blob",`+ + `"ref":{"$link":"bafkreiabc123"},"mimeType":"image/png","size":1234}`))) + + displayName, _, avatarURL := profileCache(t, database, dispatchNativeDID) + assert.Equal(t, "Alice", displayName) + assert.Empty(t, avatarURL, + "a blob ref is a CID, not a URL. Turning it into one needs the author's PDS "+ + "host plus a getBlob convention this task has not established, and a "+ + "half-derived URL would serve peers a broken avatar. Follow-up — see the "+ + "cycle F report") +} + +func TestProfileHandler_DeleteClearsTheCache(t *testing.T) { + database := dispatchTestDB(t) + seedAPActor(t, database, dispatchNativeDID, "alice") + fixture := newDispatchFixture(t, database) + + require.NoError(t, fixture.handle(t, profileFrame(dispatchNativeDID, dispatchRev, "create", + `,"displayName":"Alice Anderson","description":"posts about boats"`))) + + require.NoError(t, fixture.handle(t, + profileFrame(dispatchNativeDID, dispatchRevHigher, "delete", ""))) + + displayName, summary, avatarURL := profileCache(t, database, dispatchNativeDID) + assert.Empty(t, displayName, "deleting the profile record clears the cache") + assert.Empty(t, summary) + assert.Empty(t, avatarURL) + + var localPart string + require.NoError(t, database.QueryRowContext(context.Background(), + `SELECT local_part FROM ap_actors WHERE did = $1`, dispatchNativeDID).Scan(&localPart)) + assert.Equal(t, "alice", localPart, + "the ACTOR survives: a deleted profile record is not a deleted identity, and "+ + "the local part is frozen regardless") +} + +func TestProfileHandler_ActorlessDIDIsSkipped(t *testing.T) { + database := dispatchTestDB(t) + fixture := newDispatchFixture(t, database) + + require.NoError(t, fixture.handle(t, profileFrame(dispatchNativeDID, dispatchRev, "create", + `,"displayName":"Nobody"`)), + "a profile edit by a DID with no actor is a no-op, not an error") + + assert.Zero(t, countRows(t, database, "ap_actors"), + "editing a profile is not a FEDERATING interaction, so it must not mint: "+ + "otherwise every Coves user who ever set a display name would get an AP "+ + "identity they never asked for") + assert.Empty(t, fixture.minter.Handles()) + assert.Empty(t, fixture.resolver.Calls(), + "and no handle is resolved for an actor that is not going to exist") +} + +// --------------------------------------------------------------------------- +// F4 — #identity +// --------------------------------------------------------------------------- + +func TestIdentityHandler_UsesTheReResolvedHandleNotTheEventsOwn(t *testing.T) { + database := dispatchTestDB(t) + seedAPActor(t, database, dispatchNativeDID, "alice") + fixture := newDispatchFixture(t, database) + + // The event says one thing; current DID resolution says another. Identity + // events can be stale or replayed, so the event's handle is a hint about + // WHICH DID to re-check, never an answer about what its handle is. + fixture.resolver.handle = "carol.coves.social" + require.NoError(t, fixture.handle(t, identityFrameFor(dispatchNativeDID, "bob.coves.social"))) + + assert.Equal(t, []string{dispatchNativeDID}, fixture.resolver.Calls(), + "the handle is re-resolved and re-verified rather than taken from the frame") + + displayName, _, _ := profileCache(t, database, dispatchNativeDID) + assert.Equal(t, "carol.coves.social", displayName, + "the VERIFIED handle is cached, not the one the event carried") + + var localPart string + require.NoError(t, database.QueryRowContext(context.Background(), + `SELECT local_part FROM ap_actors WHERE did = $1`, dispatchNativeDID).Scan(&localPart)) + assert.Equal(t, "alice", localPart, + "and the local part is untouched: it was frozen at creation, and re-deriving it "+ + "would strand every federated mention of the old name") + + assert.Empty(t, fixture.enqueuer.Calls(), + "a rename enqueues nothing; peers pick it up on their own refetch") +} + +func TestIdentityHandler_ActorlessDIDResolvesNothing(t *testing.T) { + database := dispatchTestDB(t) + fixture := newDispatchFixture(t, database) + + require.NoError(t, fixture.handle(t, identityFrameFor(dispatchNativeDID, "bob.coves.social")), + "a rename by a DID with no actor is a no-op") + + assert.Empty(t, fixture.resolver.Calls(), + "the actor check comes FIRST: resolving would spend two network round-trips "+ + "(PLC + well-known) to update a cache that does not exist. Every Coves user "+ + "who renames emits one of these") + assert.Zero(t, countRows(t, database, "ap_actors"), "and no eager mint") +} + +func TestIdentityHandler_ResolverFailureLeavesTheCacheAlone(t *testing.T) { + database := dispatchTestDB(t) + seedAPActor(t, database, dispatchNativeDID, "alice") + fixture := newDispatchFixture(t, database) + + require.NoError(t, fixture.handle(t, profileFrame(dispatchNativeDID, dispatchRev, "create", + `,"displayName":"Alice Anderson"`))) + + fixture.resolver.err = fmt.Errorf("plc directory unreachable") + err := fixture.handle(t, identityFrameFor(dispatchNativeDID, "bob.coves.social")) + + require.Error(t, err, "an unverifiable rename fails the event so it can be retried") + assert.NotErrorIs(t, err, ErrPermanentEvent, "a directory outage is transient") + + displayName, _, _ := profileCache(t, database, dispatchNativeDID) + assert.Equal(t, "Alice Anderson", displayName, + "the cache keeps the last VERIFIED value rather than being cleared or filled "+ + "with the unverified handle from the frame") +} diff --git a/internal/consume/resolver.go b/internal/consume/resolver.go new file mode 100644 --- /dev/null +++ b/internal/consume/resolver.go @@ -0,0 +1,331 @@ +package consume + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "tidepool/internal/errors" +) + +// The handle resolver exists because of one gap: a Jetstream commit carries a +// DID and no handle, but personas.CreateActorForDID FREEZES the local part at +// creation. Whatever name the first federating interaction supplies is the +// name that user wears on the fediverse forever, so it cannot be guessed, and +// it cannot be taken on trust either. +// +// Hence BIDIRECTIONAL verification. The DID document's alsoKnownAs is a claim +// made BY the DID about a handle; on its own it is unverified, and a DID can +// claim any handle it likes — including one that already belongs to somebody +// else. The handle has to claim the DID back before the pair is believed. A +// one-way check would let an attacker mint @alice@coves.social by publishing +// alsoKnownAs: at://alice.coves.social in their own DID doc, and because the +// local part is frozen, that theft would be permanent. + +// DIDResolver answers "what handle does this DID verifiably own?". +type DIDResolver interface { + // ResolveDIDHandle returns the DID's bidirectionally verified handle. + // + // Failure taxonomy matters here, because the caller is a Jetstream + // handler: an error wrapping ErrPermanentEvent means the pairing can + // never be believed (no handle in the document, an unsupported DID + // method, a handle that names a different DID), while a bare error means + // "ask again later" (directory 5xx, timeouts) and stays redrivable. + ResolveDIDHandle(ctx context.Context, did string) (handle string, err error) +} + +// LookupTXTFunc resolves DNS TXT records. It matches net.Resolver.LookupTXT. +type LookupTXTFunc func(ctx context.Context, name string) ([]string, error) + +// DefaultLookupTXT is the production DNS resolver. Wire it into +// ResolverOptions.LookupTXT; it is a named function rather than an implicit +// default so that no test can silently reach the network through it. +func DefaultLookupTXT(ctx context.Context, name string) ([]string, error) { + return net.DefaultResolver.LookupTXT(ctx, name) +} + +// ResolverOptions configures a HandleResolver. +type ResolverOptions struct { + // PLCDirectoryURL is the did:plc directory DID documents are fetched from + // (config.PLCDirectoryURL, e.g. https://plc.directory). + PLCDirectoryURL string + // HTTPClient makes both the directory and the well-known requests. + // Production wires ap.NewGuardedHTTPClient(cfg.AllowPrivateAddresses, 0) + // so this egress shares the AP client's SSRF guard — which matters more + // here than almost anywhere else, because the well-known host comes from + // a DID document a stranger controls. + HTTPClient *http.Client + // UserAgent identifies the bridge on every request (config.UserAgent). + UserAgent string + // LookupTXT enables the DNS half of the handle-verification convention + // (_atproto.{handle} TXT carrying "did=..."), which is tried BEFORE the + // HTTPS well-known. Wire DefaultLookupTXT in production. + // + // Optional, and deliberately not defaulted: a resolver that silently fell + // back to the system resolver would make any test that forgot to inject + // one issue real DNS queries for the handles in its fixtures. Nil means + // verification is well-known-only, which is announced at construction + // rather than discovered from a TXT-only handle failing to verify. + LookupTXT LookupTXTFunc + Logger *slog.Logger +} + +// HandleResolver resolves and verifies a did:plc's handle. +type HandleResolver struct { + plcURL string + httpClient *http.Client + userAgent string + lookupTXT LookupTXTFunc + logger *slog.Logger +} + +var _ DIDResolver = (*HandleResolver)(nil) + +// didPLCPrefix is the only DID method this task resolves. +const didPLCPrefix = "did:plc:" + +// wellKnownDIDPath is the HTTPS half of atproto handle verification: the +// handle's own server serves the DID that owns it. +const wellKnownDIDPath = "/.well-known/atproto-did" + +// Response body caps. A DID document is a few hundred bytes and a well-known +// response is one DID; both come from hosts a stranger controls, so neither is +// read unbounded. +const ( + maxDIDDocumentBytes = 1 << 20 + maxWellKnownBytes = 1 << 10 +) + +// NewHandleResolver validates the options and builds a resolver. +func NewHandleResolver(opts ResolverOptions) (*HandleResolver, error) { + if opts.HTTPClient == nil { + // No default client. The well-known host is read out of a DID + // document a stranger controls, which makes this the most + // SSRF-exposed egress in the bridge; an unguarded http.DefaultClient + // here would happily fetch http://169.254.169.254/. + return nil, errors.NewValidationError("HTTPClient", + "must not be nil: wrap the egress with ap.NewGuardedHTTPClient") + } + parsed, err := url.Parse(opts.PLCDirectoryURL) + if err != nil { + return nil, errors.NewValidationError("PLCDirectoryURL", err.Error()) + } + if parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, errors.NewValidationError("PLCDirectoryURL", + "must be an absolute http(s) URL, got "+opts.PLCDirectoryURL) + } + + logger := opts.Logger + if logger == nil { + logger = slog.Default() + } + if opts.LookupTXT == nil { + logger.Warn("handle resolver has no DNS resolver: handles that publish only a " + + "_atproto TXT record cannot be verified (pass consume.DefaultLookupTXT)") + } + return &HandleResolver{ + plcURL: strings.TrimSuffix(opts.PLCDirectoryURL, "/"), + httpClient: opts.HTTPClient, + userAgent: opts.UserAgent, + lookupTXT: opts.LookupTXT, + logger: logger, + }, nil +} + +// ResolveDIDHandle fetches the DID document, reads the handle it claims via +// alsoKnownAs, and confirms the handle claims the DID back before returning +// it. +// +// The reverse check tries the DNS convention first (_atproto.{handle} TXT +// carrying "did=...", when a LookupTXT is wired) and falls back to the HTTPS +// well-known (GET https://{handle}/.well-known/atproto-did, whose body is the +// DID) — the same order the atproto spec gives for handle resolution. +func (r *HandleResolver) ResolveDIDHandle(ctx context.Context, did string) (string, error) { + if err := validatePLCDID(did); err != nil { + // Rejected before any network call: a did:web sent to a PLC directory + // is a wasted request at best. + return "", err + } + + document, err := r.fetchDIDDocument(ctx, did) + if err != nil { + return "", err + } + + handle, err := handleFromAlsoKnownAs(document.AlsoKnownAs) + if err != nil { + return "", fmt.Errorf("%s: %w", did, err) + } + + if err := r.verifyHandleClaimsDID(ctx, handle, did); err != nil { + return "", err + } + return handle, nil +} + +// didDocument is the sliver of a DID document this resolver reads. +type didDocument struct { + AlsoKnownAs []string `json:"alsoKnownAs"` +} + +// fetchDIDDocument reads the DID document from the PLC directory. Every +// non-200 is TRANSIENT, 404 included: this event exists because the repo +// committed, so the DID does exist, and a directory that has not caught up yet +// is propagation lag rather than a nonexistent identity. +func (r *HandleResolver) fetchDIDDocument(ctx context.Context, did string) (*didDocument, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, r.plcURL+"/"+did, nil) + if err != nil { + return nil, fmt.Errorf("build DID document request for %s: %w", did, err) + } + request.Header.Set("Accept", "application/did+ld+json, application/json") + if r.userAgent != "" { + request.Header.Set("User-Agent", r.userAgent) + } + + response, err := r.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("fetch DID document for %s: %w", did, err) + } + defer func() { _ = response.Body.Close() }() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch DID document for %s: directory returned %d", did, response.StatusCode) + } + + var document didDocument + if err := json.NewDecoder(io.LimitReader(response.Body, maxDIDDocumentBytes)).Decode(&document); err != nil { + // A directory serving a body that is not a DID document is + // transient: it is a fact about the directory, not about the DID. + return nil, fmt.Errorf("decode DID document for %s: %w", did, err) + } + return &document, nil +} + +// handleFromAlsoKnownAs picks the first entry that is actually an atproto +// handle. alsoKnownAs is a general-purpose field — https:// profile links and +// mailto: addresses live there too — so only at:// entries are considered, and +// each is parsed as a handle before it is believed: the value ends up in a URL +// HOST, so "evil.example/path" must never reach a request. +func handleFromAlsoKnownAs(alsoKnownAs []string) (string, error) { + for _, entry := range alsoKnownAs { + candidate, found := strings.CutPrefix(entry, "at://") + if !found { + continue + } + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if _, err := syntax.ParseHandle(candidate); err != nil { + continue + } + return candidate, nil + } + // RULED PERMANENT: the document is a complete answer, and it says this DID + // claims no handle. Nothing about retrying changes what the document says, + // so it is dead-lettered exhausted rather than redriven — the recovery + // path is manual, by design. + return "", fmt.Errorf("%w: DID document claims no atproto handle", ErrPermanentEvent) +} + +// verifyHandleClaimsDID is the reverse direction: the handle must name the DID +// back. DNS is authoritative when it answers; the well-known is the fallback +// the spec allows, and is what most PDS-hosted handles use. +func (r *HandleResolver) verifyHandleClaimsDID(ctx context.Context, handle, did string) error { + if r.lookupTXT != nil { + claimed, found := r.lookupTXTDID(ctx, handle) + if found { + if claimed == did { + return nil + } + return fmt.Errorf("%w: handle %s claims %s, not %s", ErrPermanentEvent, handle, claimed, did) + } + } + return r.verifyWellKnown(ctx, handle, did) +} + +// atprotoTXTPrefix is the subdomain the handle's DID claim is published under. +const atprotoTXTPrefix = "_atproto." + +// lookupTXTDID reads the DID a handle publishes over DNS. A lookup error or a +// missing record is reported as "not found" rather than as a failure: the +// well-known fallback is the answer for every handle that does not use DNS, +// and DNS being unreachable must not condemn one that does. +func (r *HandleResolver) lookupTXTDID(ctx context.Context, handle string) (did string, found bool) { + records, err := r.lookupTXT(ctx, atprotoTXTPrefix+handle) + if err != nil { + r.logger.Debug("no _atproto TXT record; falling back to the well-known", + slog.String("handle", handle), slog.String("error", err.Error())) + return "", false + } + for _, record := range records { + if claimed, ok := strings.CutPrefix(strings.TrimSpace(record), "did="); ok { + return strings.TrimSpace(claimed), true + } + } + return "", false +} + +// verifyWellKnown fetches https://{handle}/.well-known/atproto-did and +// compares it to the DID. +// +// A 200 naming a DIFFERENT DID is the impersonation case and is permanent: the +// handle has answered, and the answer is no. Everything else — a 5xx, a +// network error, or a 404 — is transient. A 404 in particular is NOT a +// disavowal: the handle may publish its claim over DNS only, or its owner may +// not have finished setting it up, and both become true later. +func (r *HandleResolver) verifyWellKnown(ctx context.Context, handle, did string) error { + endpoint := "https://" + handle + wellKnownDIDPath + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("build well-known request for %s: %w", handle, err) + } + request.Header.Set("Accept", "text/plain") + if r.userAgent != "" { + request.Header.Set("User-Agent", r.userAgent) + } + + response, err := r.httpClient.Do(request) + if err != nil { + return fmt.Errorf("verify handle %s: %w", handle, err) + } + defer func() { _ = response.Body.Close() }() + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("verify handle %s: well-known returned %d", handle, response.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(response.Body, maxWellKnownBytes)) + if err != nil { + return fmt.Errorf("verify handle %s: read well-known: %w", handle, err) + } + // Real PDSes serve the DID with a trailing newline; a byte-exact + // comparison would reject every genuine handle on the network. + if claimed := strings.TrimSpace(string(body)); claimed != did { + return fmt.Errorf("%w: handle %s claims %s, not %s", ErrPermanentEvent, handle, claimed, did) + } + return nil +} + +// validatePLCDID rejects everything this task cannot resolve, and does it +// before any network call. The identifier is also charset-checked because it +// is interpolated into the directory URL's PATH: a DID carrying a slash would +// address a different endpoint entirely. +func validatePLCDID(did string) error { + identifier, isPLC := strings.CutPrefix(did, didPLCPrefix) + if !isPLC || identifier == "" { + return fmt.Errorf("%w: %q is not a did:plc, which is the only method this consumer resolves", + ErrPermanentEvent, did) + } + for _, char := range identifier { + if (char < 'a' || char > 'z') && (char < '0' || char > '9') { + return fmt.Errorf("%w: did:plc identifier %q is not base32", ErrPermanentEvent, identifier) + } + } + return nil +} diff --git a/internal/consume/resolver_test.go b/internal/consume/resolver_test.go new file mode 100644 --- /dev/null +++ b/internal/consume/resolver_test.go @@ -0,0 +1,216 @@ +package consume + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/errors" +) + +// Task 14 cycle F1: DID → handle resolution with BIDIRECTIONAL verification. +// +// The stake is unusually high for a lookup. personas freezes the local part at +// creation, so whatever handle reaches the first mint is the name that user +// wears on the fediverse permanently. alsoKnownAs alone is a claim a stranger +// writes in their own DID document; believing it one-way would let anyone mint +// @alice@coves.social by naming alice's handle in their doc, and freezing +// would make the theft irreversible. + +const ( + resolveDID = "did:plc:7iza6de2dwap2sbkpav7c6c6" + resolveHandle = "alice.coves.social" + resolveOtherDID = "did:plc:44ybard66vv44zksje25o7dz" + resolveOtherHandle = "mallory.coves.social" +) + +func TestHandleResolver_VerifiesBothDirections(t *testing.T) { + fake := newFakeIdentity(t) + fake.claim(resolveDID, resolveHandle) + + handle, err := fake.resolver(t).ResolveDIDHandle(context.Background(), resolveDID) + require.NoError(t, err) + assert.Equal(t, resolveHandle, handle) + + assert.Equal(t, 1, fake.PLCHits(), "the DID document is fetched") + assert.Equal(t, 1, fake.WellKnownHits(), + "and the handle is asked to claim the DID BACK — a resolver that skipped this "+ + "would accept any handle a DID document names, including somebody else's") +} + +func TestHandleResolver_ToleratesTheTrailingNewlineRealPDSesServe(t *testing.T) { + fake := newFakeIdentity(t) + fake.claim(resolveDID, resolveHandle) + + // The fake serves "did\n", as real PDSes do. A byte-exact comparison would + // reject every genuine handle on the network. + handle, err := fake.resolver(t).ResolveDIDHandle(context.Background(), resolveDID) + require.NoError(t, err) + assert.Equal(t, resolveHandle, handle) +} + +func TestHandleResolver_ImpersonationIsRefused(t *testing.T) { + fake := newFakeIdentity(t) + // Mallory's DID document claims alice's handle... + fake.claimOneWay(resolveOtherDID, resolveHandle) + // ...but the handle belongs to alice, and says so. + fake.wellKnownReturns(resolveHandle, resolveDID) + + handle, err := fake.resolver(t).ResolveDIDHandle(context.Background(), resolveOtherDID) + + require.Error(t, err, + "a DID claiming a handle that names a DIFFERENT DID must be refused: the local "+ + "part is frozen at creation, so accepting this would hand mallory alice's "+ + "fediverse name permanently") + assert.Empty(t, handle, "no handle may be returned alongside the error") + assert.ErrorIs(t, err, ErrPermanentEvent, + "a false claim cannot become true by retrying, so it is dead-lettered exhausted "+ + "rather than redriven") +} + +func TestHandleResolver_UnverifiableHandleIsRefused(t *testing.T) { + fake := newFakeIdentity(t) + // The document names a handle that serves no well-known at all. + fake.claimOneWay(resolveDID, resolveHandle) + + handle, err := fake.resolver(t).ResolveDIDHandle(context.Background(), resolveDID) + + require.Error(t, err, + "an unverifiable handle is refused rather than trusted: one-way is exactly the "+ + "claim an attacker can forge") + assert.Empty(t, handle) + assert.Equal(t, 1, fake.WellKnownHits(), "the reverse check was actually attempted") +} + +func TestHandleResolver_TransientFailuresStayRedrivable(t *testing.T) { + tests := []struct { + name string + setup func(*fakeIdentity) + why string + }{ + { + name: "directory 503", + setup: func(f *fakeIdentity) { f.plcFails(resolveDID, http.StatusServiceUnavailable) }, + why: "a directory outage says nothing about the DID; asking again later is the fix", + }, + { + name: "directory 500", + setup: func(f *fakeIdentity) { f.plcFails(resolveDID, http.StatusInternalServerError) }, + why: "same", + }, + { + name: "directory 404", + setup: func(f *fakeIdentity) { + // No claim registered at all: the DID is simply not there yet. + // PLC propagation lags account creation, and this event only + // exists because the repo committed, so the DID does exist. + }, + why: "a DID that just committed but is not in the directory yet is a propagation " + + "lag, not a nonexistent identity", + }, + { + name: "well-known 500", + setup: func(f *fakeIdentity) { + f.claim(resolveDID, resolveHandle) + f.wellKnownFails(resolveHandle, http.StatusInternalServerError) + }, + why: "the handle's server being down is not the handle disowning the DID", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fake := newFakeIdentity(t) + if tc.name != "directory 404" { + fake.claim(resolveDID, resolveHandle) + } + tc.setup(fake) + + handle, err := fake.resolver(t).ResolveDIDHandle(context.Background(), resolveDID) + + require.Error(t, err) + assert.Empty(t, handle, "no mint may proceed on an unresolved handle") + assert.NotErrorIs(t, err, ErrPermanentEvent, + "%s — a permanent classification would exhaust the redrive budget "+ + "immediately and strand the event with no automatic recovery", tc.why) + }) + } +} + +func TestHandleResolver_MissingHandleIsPermanent(t *testing.T) { + fake := newFakeIdentity(t) + // A valid DID document with no alsoKnownAs at all. + fake.plcServes(resolveDID, `{"@context":["https://www.w3.org/ns/did/v1"], + "id":"`+resolveDID+`","alsoKnownAs":[],"verificationMethod":[],"service":[]}`) + + handle, err := fake.resolver(t).ResolveDIDHandle(context.Background(), resolveDID) + + require.Error(t, err, "a DID with no handle cannot be given a frozen local part") + assert.Empty(t, handle) + assert.ErrorIs(t, err, ErrPermanentEvent, + "RULED PERMANENT: the document states the DID has no handle, which is an answer "+ + "rather than a failure. See the cycle F report — permanent events are "+ + "excluded from redrive by design, so the recovery path is manual") + + assert.Zero(t, fake.WellKnownHits(), + "and nothing is fetched from the network on a claim that does not exist") +} + +func TestHandleResolver_NonATProtoAlsoKnownAsIsPermanent(t *testing.T) { + fake := newFakeIdentity(t) + // alsoKnownAs is a general-purpose field; only at:// entries are handles. + fake.plcServes(resolveDID, `{"id":"`+resolveDID+`", + "alsoKnownAs":["https://alice.example/profile","mailto:alice@example.com"]}`) + + handle, err := fake.resolver(t).ResolveDIDHandle(context.Background(), resolveDID) + + require.Error(t, err) + assert.Empty(t, handle, + "a non-atproto alsoKnownAs entry is not a handle and must never be read as one") + assert.ErrorIs(t, err, ErrPermanentEvent) +} + +func TestHandleResolver_UnsupportedDIDMethods(t *testing.T) { + fake := newFakeIdentity(t) + resolver := fake.resolver(t) + + for _, did := range []string{ + "did:web:alice.coves.social", + "did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme", + "not-a-did", + "", + } { + t.Run(did, func(t *testing.T) { + handle, err := resolver.ResolveDIDHandle(context.Background(), did) + require.Error(t, err, "only did:plc is resolvable this task") + assert.Empty(t, handle) + assert.ErrorIs(t, err, ErrPermanentEvent, + "an unsupported DID method never becomes supported by retrying") + }) + } + + assert.Zero(t, fake.PLCHits(), + "an unsupported method is rejected before any network call — a did:web sent to "+ + "a PLC directory is a wasted request at best") +} + +func TestNewHandleResolver_RequiresGuardedEgress(t *testing.T) { + _, err := NewHandleResolver(ResolverOptions{PLCDirectoryURL: "https://plc.directory"}) + require.Error(t, err, + "the HTTP client is required: the well-known host comes from a DID document a "+ + "STRANGER controls, so this is the most SSRF-exposed egress in the bridge "+ + "and must not fall back to an unguarded default") + assert.True(t, errors.IsValidation(err), "want validation error, got %v", err) + assert.Contains(t, err.Error(), "NewGuardedHTTPClient", + "the message must name the constructor a caller is supposed to use") + + _, err = NewHandleResolver(ResolverOptions{ + PLCDirectoryURL: "notaurl", + HTTPClient: http.DefaultClient, + }) + require.Error(t, err, "the directory URL must be an absolute http(s) URL") + assert.True(t, errors.IsValidation(err), "want validation error, got %v", err) +}