// Package connection implements writing quest.atmo.connection records and // queueing reciprocal writes for users who aren't currently logged in. // // The lexicon (lexicons/quest/atmo/connection.json) defines a record with: // // { with: did, connectedAt: datetime, event?: at-uri } // // One record per (viewer, target [, event]) tuple, keyed by a TID rkey. There // can be multiple connection records to the same target across different // events. // // Writes go through the user's OAuth session and require the // `repo:quest.atmo.connection` scope. // // Async reciprocity: // // - When user A (logged in) scans user B's QR, we write A's record // synchronously to A's PDS. // - We *also* try to write B's reciprocal record. If B has a usable OAuth // session in our store we do it inline; otherwise we Enqueue a row in // pending_connections and drain it the next time B logs in. // // The package is intentionally small and side-effect-free — handlers compose // it with the OAuth + DB layers. package connection import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" ) const ( // NSID of the connection record lexicon. NSID = "quest.atmo.connection" // createRecord is the XRPC procedure we POST to. nsidCreateRecord = "com.atproto.repo.createRecord" // putRecord is the XRPC procedure for in-place updates. nsidPutRecord = "com.atproto.repo.putRecord" ) // Record models a connection record we're about to write. Author is the DID // whose PDS we're writing to (not part of the record value — derived from // the session). type Record struct { With syntax.DID ConnectedAt time.Time // EventURI is an optional at-uri linking this connection to an event. // Not surfaced yet; reserved for the events feature. EventURI string } // Put writes a new connection record to the authenticated user's PDS via // com.atproto.repo.createRecord. Each call produces a separate record (TID // rkey), so calling Put twice for the same `with` legitimately creates two // records — that's by design per the spec (e.g. across multiple events). // // Caller must have the `repo:quest.atmo.connection` scope on the session. // If db is non-nil, also writes a connection bookmark row (empty note) so // CountConnectionsForDID can accurately count the user's connections. func Put(ctx context.Context, sess *oauth.ClientSession, db *sql.DB, rec Record) (uri, cid string, err error) { if sess == nil { return "", "", errors.New("connection: nil oauth session") } if rec.With == "" { return "", "", errors.New("connection: missing target DID") } if sess.Data.AccountDID == rec.With { return "", "", errors.New("connection: cannot connect to yourself") } if rec.ConnectedAt.IsZero() { rec.ConnectedAt = time.Now().UTC() } value := buildConnectionValue(rec.With, rec.ConnectedAt, rec.EventURI) input := map[string]any{ "repo": sess.Data.AccountDID.String(), "collection": NSID, "record": value, } var out struct { URI string `json:"uri"` CID string `json:"cid"` } if err := sess.APIClient().Post(ctx, syntax.NSID(nsidCreateRecord), input, &out); err != nil { return "", "", fmt.Errorf("createRecord %s: %w", NSID, err) } // Write connection bookmark (best-effort) if db != nil { viewerDID := sess.Data.AccountDID.String() _, _ = db.ExecContext(ctx, ` INSERT INTO connection_notes (viewer_did, target_did, notes, follow_up, updated_at) VALUES (?, ?, '', 0, CURRENT_TIMESTAMP) ON CONFLICT(viewer_did, target_did) DO NOTHING `, viewerDID, rec.With.String()) } return out.URI, out.CID, nil } // buildConnectionValue assembles the record body for a quest.atmo.connection // record. The event field is included only when eventURI is non-empty. func buildConnectionValue(with syntax.DID, connectedAt time.Time, eventURI string) map[string]any { value := map[string]any{ "$type": NSID, "with": with.String(), "connectedAt": connectedAt.UTC().Format(time.RFC3339), } if eventURI != "" { value["event"] = eventURI } return value } // SetEvent rewrites an existing connection record in place (putRecord, reusing // the record's rkey) so its event association becomes eventURI. Passing an // empty eventURI clears the association. The caller carries the record's // current `with` and `connectedAt` (from the list entry) so no fields are // dropped. The at-uri stays stable because the rkey is reused. // // Caller must hold the repo:quest.atmo.connection OAuth scope. func SetEvent(ctx context.Context, sess *oauth.ClientSession, recordURI string, with syntax.DID, connectedAt time.Time, eventURI string) error { if sess == nil { return errors.New("connection: nil oauth session") } rkey := rkeyFromURI(recordURI) if rkey == "" { return fmt.Errorf("connection: cannot parse rkey from %q", recordURI) } if connectedAt.IsZero() { connectedAt = time.Now().UTC() } input := map[string]any{ "repo": sess.Data.AccountDID.String(), "collection": NSID, "rkey": rkey, // `validate` omitted — see Put for rationale. "record": buildConnectionValue(with, connectedAt, eventURI), } if err := sess.APIClient().Post(ctx, syntax.NSID(nsidPutRecord), input, nil); err != nil { return fmt.Errorf("putRecord %s: %w", NSID, err) } return nil } // rkeyFromURI extracts the record key (last path segment) from an at:// URI. func rkeyFromURI(uri string) string { if uri == "" { return "" } parts := strings.Split(uri, "/") return parts[len(parts)-1] }