···2233import (
44 "context"
55- "encoding/json"
65 "fmt"
76 "log"
87 "net/http"
98 "strconv"
109 "strings"
1111- "time"
12101313- "atcr.io/pkg/atproto"
1411 "atcr.io/pkg/hold"
1512 "atcr.io/pkg/hold/pds"
1616- indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
17131814 // Import storage drivers
1915 _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
···4844 log.Fatalf("Failed to initialize embedded PDS: %v", err)
4945 }
50465151- // Bootstrap PDS with hold owner as first crew member
5252- if err := holdPDS.Bootstrap(ctx, cfg.Registration.OwnerDID); err != nil {
4747+ // Bootstrap PDS with captain record and hold owner as first crew member
4848+ if err := holdPDS.Bootstrap(ctx, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew); err != nil {
5349 log.Fatalf("Failed to bootstrap PDS: %v", err)
5450 }
5551···114110 service.HandleMultipartPartUpload(w, r, uploadID, partNumber, did, service.MultipartMgr)
115111 })
116112117117- // Pre-register OAuth callback route (will be populated by auto-registration)
118118- var oauthCallbackHandler http.HandlerFunc
119119- mux.HandleFunc("/auth/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
120120- if oauthCallbackHandler != nil {
121121- oauthCallbackHandler(w, r)
122122- } else {
123123- http.Error(w, "OAuth callback not initialized", http.StatusServiceUnavailable)
124124- }
125125- })
126126-127127- // OAuth client metadata endpoint for ATProto OAuth
128128- // The hold service serves its metadata at /client-metadata.json
129129- // This is referenced by its client ID URL
130130- mux.HandleFunc("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
131131- // Create a temporary config to generate metadata (indigo provides this)
132132- redirectURI := cfg.Server.PublicURL + "/auth/oauth/callback"
133133- clientID := cfg.Server.PublicURL + "/client-metadata.json"
134134-135135- // Define scopes needed for hold registration and crew management
136136- // Omit action parameter to allow all actions (create, update, delete)
137137- scopes := []string{
138138- "atproto",
139139- fmt.Sprintf("repo:%s", atproto.HoldCollection),
140140- fmt.Sprintf("repo:%s", atproto.HoldCrewCollection),
141141- fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
142142- }
143143-144144- config := indigooauth.NewPublicConfig(clientID, redirectURI, scopes)
145145- metadata := config.ClientMetadata()
146146-147147- // Serve as JSON
148148- w.Header().Set("Content-Type", "application/json")
149149- w.Header().Set("Access-Control-Allow-Origin", "*")
150150- json.NewEncoder(w).Encode(metadata)
151151- })
152113 mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) {
153114 switch r.Method {
154115 case http.MethodGet, http.MethodHead:
···182143 serverErr <- err
183144 }
184145 }()
185185-186186- // Give server a moment to start
187187- time.Sleep(100 * time.Millisecond)
188188-189189- // Auto-register if owner DID is set (now that server is running)
190190- if cfg.Registration.OwnerDID != "" {
191191- if err := service.AutoRegister(&oauthCallbackHandler); err != nil {
192192- log.Printf("WARNING: Auto-registration failed: %v", err)
193193- log.Printf("You can register manually later using the /register endpoint")
194194- } else {
195195- log.Printf("Successfully registered hold service in PDS")
196196- }
197197-198198- // Reconcile allow-all crew state
199199- if err := service.ReconcileAllowAllCrew(&oauthCallbackHandler); err != nil {
200200- log.Printf("WARNING: Failed to reconcile allow-all crew state: %v", err)
201201- }
202202- }
203146204147 // Wait for server error or shutdown
205148 if err := <-serverErr; err != nil {
+267-18
docs/EMBEDDED_PDS.md
···146146│ ├── io.atcr.hold.exportImage (data portability)
147147│ └── io.atcr.hold.getStats (metadata)
148148└── Records (hold's own PDS):
149149- ├── io.atcr.hold.crew (crew membership)
149149+ ├── io.atcr.hold.captain (single record: ownership & metadata)
150150+ ├── io.atcr.hold.crew/* (crew membership & permissions)
150151 └── io.atcr.hold.config (hold configuration)
151152```
152153···270271271272**This is standard ATProto federation** - services pass OAuth tokens with DPoP proofs between each other. Hold independently validates tokens against the user's PDS, so there's no trust relationship required.
272273273273-**Crew records stored in hold's PDS:**
274274+**Records stored in hold's PDS:**
275275+274276```json
277277+// io.atcr.hold.captain (single record - hold metadata)
278278+{
279279+ "$type": "io.atcr.hold.captain",
280280+ "owner": "did:plc:alice123",
281281+ "public": false,
282282+ "deployedAt": "2025-10-14T...",
283283+ "region": "iad",
284284+ "provider": "fly.io"
285285+}
286286+287287+// io.atcr.hold.crew/* (access control records)
275288{
276289 "$type": "io.atcr.hold.crew",
277290 "member": "did:plc:alice123",
···280293 "addedAt": "2025-10-14T..."
281294}
282295```
296296+297297+**Semantic separation:**
298298+- **Captain record** = Hold ownership and metadata (who owns it, where it's deployed)
299299+- **Crew records** = Access control (who can use it, what permissions they have)
283300284301**Security considerations:**
285302- User's OAuth token is exposed to hold during delegation
···467484468485### 6. Hold Discovery & Registration
469486470470-**Current:** Hold registers by creating records in owner's PDS
471471-**New:** Hold is its own identity - how does AppView discover available holds?
487487+**Decision: No registration records needed in owner's PDS.**
488488+489489+Since holds are ATProto actors with did:web identity, they are self-describing:
490490+491491+**Hold's PDS contains everything:**
492492+```
493493+did:web:hold01.atcr.io
494494+├── io.atcr.hold.captain → { owner: "did:plc:alice123", ... }
495495+└── io.atcr.hold.crew/* → Access control records
496496+```
497497+498498+**DID Document with Multiple Services:**
499499+500500+Holds expose multiple service endpoints to distinguish themselves from generic PDSs:
501501+502502+```json
503503+{
504504+ "@context": ["https://www.w3.org/ns/did/v1", ...],
505505+ "id": "did:web:hold01.atcr.io",
506506+ "service": [
507507+ {
508508+ "id": "#atproto_pds",
509509+ "type": "AtprotoPersonalDataServer",
510510+ "serviceEndpoint": "https://hold01.atcr.io"
511511+ },
512512+ {
513513+ "id": "#atcr_hold",
514514+ "type": "AtcrHoldService",
515515+ "serviceEndpoint": "https://hold01.atcr.io"
516516+ }
517517+ ]
518518+}
519519+```
520520+521521+**Service semantics:**
522522+- **`#atproto_pds`** - Standard ATProto PDS operations (crew queries, record sync)
523523+- **`#atcr_hold`** - ATCR-specific operations (blob storage, presigned URLs)
524524+525525+**Discovery patterns:**
526526+527527+1. **Direct deployment** - Owner deploys hold, knows the DID
528528+2. **Sailor profiles** - Users reference holds by DID in their profile
529529+3. **DID resolution** - `did:web:hold01.atcr.io` → `https://hold01.atcr.io/.well-known/did.json`
530530+4. **Service lookup** - Check for `#atcr_hold` service to identify ATCR holds
531531+5. **Crew queries** - AppView queries hold's PDS directly via `#atproto_pds` endpoint
532532+533533+**AppView resolution flow:**
534534+```go
535535+// 1. Get hold DID from sailor profile
536536+holdDID := profile.DefaultHold // "did:web:hold01.atcr.io"
537537+538538+// 2. Resolve DID document
539539+didDoc := resolveDidWeb(holdDID)
540540+541541+// 3. Extract service endpoints
542542+pdsEndpoint := didDoc.GetService("#atproto_pds") // XRPC operations
543543+holdEndpoint := didDoc.GetService("#atcr_hold") // Blob operations
544544+545545+// 4. Query crew list via PDS endpoint
546546+crew := xrpcClient.ListRecords(pdsEndpoint, "io.atcr.hold.crew")
472547473473-Possibilities:
474474-- Holds publish to feeds
475475-- AppView maintains directory
476476-- DIDs are manually configured
477477-- ATProto directory service
548548+// 5. Check if user has access
549549+hasAccess := crew.Contains(userDID)
550550+```
551551+552552+**No need for reverse lookup** (owner → holds). Users know their holds because they deployed them.
553553+554554+**Benefits:**
555555+- ✅ Single source of truth (hold's PDS)
556556+- ✅ No cross-PDS writes during registration
557557+- ✅ Self-describing ATProto actors
558558+- ✅ Standard DID resolution patterns
559559+- ✅ Clear service semantics (PDS vs ATCR-specific)
560560+- ✅ Discoverable via service type
561561+562562+**OAuth implications:**
563563+- OAuth registration flow no longer needed (hold is self-describing)
564564+- OAuth code kept for backward compatibility with legacy registration records
565565+- Future: Remove OAuth after migration period
478566479567### 7. Multi-Tenancy
480568···603691604692**Key insight:** Other ATProto services will "just work" as long as they can retrieve records from the hold's PDS. We don't need to implement full social features for the hold to participate in the ecosystem.
605693606606-### Crew Management: Individual Records
694694+### Crew Management: Captain + Individual Records
607695608608-**Decision: Individual crew record per user (remove wildcard logic)**
696696+**Decision: Captain record (ownership) + Individual crew records (access control)**
609697610698```json
611611-// io.atcr.hold.crew/{rkey}
699699+// io.atcr.hold.captain (single record - hold metadata)
700700+{
701701+ "$type": "io.atcr.hold.captain",
702702+ "owner": "did:plc:alice123",
703703+ "public": false,
704704+ "deployedAt": "2025-10-14T...",
705705+ "region": "iad",
706706+ "provider": "fly.io"
707707+}
708708+709709+// io.atcr.hold.crew/{rkey} (access control)
612710{
613711 "$type": "io.atcr.hold.crew",
614712 "member": "did:plc:alice123",
···617715 "addedAt": "2025-10-14T..."
618716}
619717620620-// io.atcr.hold.config/policy
718718+// io.atcr.hold.config/policy (optional)
621719{
622720 "$type": "io.atcr.hold.config",
623721 "access": "public", // or "allowlist"
···627725}
628726```
629727728728+**Semantic separation:**
729729+- **Captain record** = Who owns/deployed the hold (billing, deletion, migration rights)
730730+- **Crew records** = Who can use the hold (access control, permissions)
731731+- **Config record** = Hold-wide policies
732732+630733**Authorization logic:**
631734```go
632735func (p *HoldPDS) CheckAccess(ctx context.Context, userDID string) (bool, error) {
···661764- **Private team hold:** `access: "allowlist"` - explicit crew membership
662765- **Hybrid:** Public access + explicit admin crew records for elevated permissions
663766767767+### Phase 2: XRPC Endpoints Implementation ✅ COMPLETED
768768+769769+**Critical Implementation Lessons Learned:**
770770+771771+#### 1. Custom Record Types Require Manual CBOR Decoding
772772+773773+Indigo's `repo.GetRecord()` uses its lexicon decoder which only knows about built-in ATProto types. For custom types, you must use `GetRecordBytes()` and decode manually:
774774+775775+```go
776776+// ❌ WRONG - Fails with "unrecognized lexicon type"
777777+record, err := repo.GetRecord(ctx, path, &CrewRecord{})
778778+779779+// ✅ CORRECT - Manual CBOR decoding
780780+recordCID, recBytes, err := repo.GetRecordBytes(ctx, path)
781781+var crewRecord CrewRecord
782782+err = crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes))
783783+```
784784+785785+**Why:** Indigo's lexicon system doesn't know about `io.atcr.hold.crew` or other custom types.
786786+787787+#### 2. JSON Struct Tags Must Match CBOR Tags Exactly
788788+789789+For CID verification to work, JSON and CBOR encodings must produce identical bytes:
790790+791791+```go
792792+// ❌ WRONG - JSON uses capital field names (Member, Role)
793793+type CrewRecord struct {
794794+ Type string `cborgen:"$type"`
795795+ Member string `cborgen:"member"`
796796+ Role string `cborgen:"role"`
797797+ Permissions []string `cborgen:"permissions"`
798798+ AddedAt string `cborgen:"addedAt"`
799799+}
800800+801801+// ✅ CORRECT - JSON tags match CBOR tags
802802+type CrewRecord struct {
803803+ Type string `json:"$type" cborgen:"$type"`
804804+ Member string `json:"member" cborgen:"member"`
805805+ Role string `json:"role" cborgen:"role"`
806806+ Permissions []string `json:"permissions" cborgen:"permissions"`
807807+ AddedAt string `json:"addedAt" cborgen:"addedAt"`
808808+}
809809+```
810810+811811+**Why:** Verification code CBOR-encodes the JSON record and compares the CID. Mismatched field names produce different bytes and thus different CIDs.
812812+813813+#### 3. MST ForEach Returns Full Paths
814814+815815+The `repo.ForEach()` callback receives full collection paths, not just record keys:
816816+817817+```go
818818+// ❌ WRONG - Prepends collection prefix again
819819+err := repo.ForEach(ctx, "io.atcr.hold.crew", func(k string, v cid.Cid) error {
820820+ // k is already "io.atcr.hold.crew/3m37dr2ddit22"
821821+ path := fmt.Sprintf("%s/%s", collection, k) // Double path!
822822+ return nil
823823+})
824824+825825+// ✅ CORRECT - Extract just the rkey
826826+err := repo.ForEach(ctx, "io.atcr.hold.crew", func(k string, v cid.Cid) error {
827827+ // k = "io.atcr.hold.crew/3m37dr2ddit22"
828828+ parts := strings.Split(k, "/")
829829+ rkey := parts[len(parts)-1] // "3m37dr2ddit22"
830830+ return nil
831831+})
832832+```
833833+834834+#### 4. All Record Endpoints Must Return CIDs
835835+836836+Per ATProto spec, `com.atproto.repo.getRecord` and `listRecords` must include the record's CID:
837837+838838+```go
839839+// ✅ CORRECT - Include CID in response
840840+response := map[string]any{
841841+ "uri": fmt.Sprintf("at://%s/%s/%s", did, collection, rkey),
842842+ "cid": recordCID.String(), // Required!
843843+ "value": record,
844844+}
845845+```
846846+847847+**Why:** Clients need the CID to verify record integrity via `com.atproto.sync.getRecord`.
848848+849849+#### 5. sync.getRecord CAR Files Must Include Full MST Path
850850+851851+The `com.atproto.sync.getRecord` endpoint must return a CAR file with ALL blocks needed to verify the record:
852852+853853+```go
854854+// ❌ WRONG - Only includes the record block
855855+blk, _ := repo.Blockstore().Get(ctx, recordCID)
856856+// Write single block to CAR
857857+858858+// ✅ CORRECT - Capture all accessed blocks
859859+loggingBS := util.NewLoggingBstore(session)
860860+tempRepo, _ := repo.OpenRepo(ctx, loggingBS, repoHead)
861861+_, _, _ = tempRepo.GetRecordBytes(ctx, path)
862862+blocks := loggingBS.GetLoggedBlocks() // Commit + MST nodes + record
863863+// Write all blocks to CAR
864864+```
865865+866866+**Components included:**
867867+1. **Commit block** - Repo head with signature, data root, version
868868+2. **MST tree nodes** - Path from root to record (log N depth)
869869+3. **Record block** - The actual record data
870870+871871+**Why:** Clients need the full Merkle path to cryptographically verify the record against the repo head.
872872+873873+#### 6. CAR Root Must Be Repo Head, Not Record CID
874874+875875+The CAR file's root CID must be the repo head (commit), not the record:
876876+877877+```go
878878+// ❌ WRONG - Uses record CID as root
879879+header := &car.CarHeader{
880880+ Roots: []cid.Cid{recordCID},
881881+ Version: 1,
882882+}
883883+884884+// ✅ CORRECT - Uses repo head as root
885885+repoHead, _ := carstore.GetUserRepoHead(ctx, uid)
886886+header := &car.CarHeader{
887887+ Roots: []cid.Cid{repoHead}, // Commit CID
888888+ Version: 1,
889889+}
890890+```
891891+892892+**Why:** The CAR represents a slice of the repo from head to record, not just the record itself.
893893+894894+#### 7. Empty Collections Should Return Empty Arrays
895895+896896+Handle empty collections gracefully instead of returning errors:
897897+898898+```go
899899+// ✅ CORRECT - Return empty array for missing collection
900900+err := repo.ForEach(ctx, collection, func(k string, v cid.Cid) error {
901901+ // ...
902902+})
903903+if err != nil {
904904+ if err.Error() == "mst: not found" {
905905+ return []*CrewMemberWithKey{}, nil // Empty collection
906906+ }
907907+ return nil, err // Real error
908908+}
909909+```
910910+911911+**Why:** ATProto expects empty arrays for non-existent collections, not 404 errors.
912912+664913### Next Steps
665914666666-1. **Add indigo dependencies** - carstore, repo, MST
667667-2. **Implement HoldPDS with carstore** - Create pkg/hold/pds
668668-3. **Add crew management** - CRUD operations for crew records
669669-4. **Implement standard PDS endpoints** - describeServer, describeRepo, getRecord, listRecords
670670-5. **Add DID document** - did:web identity generation
915915+1. ~~**Add indigo dependencies**~~ ✅
916916+2. ~~**Implement HoldPDS with carstore**~~ ✅
917917+3. ~~**Add crew management**~~ ✅
918918+4. ~~**Implement standard PDS endpoints**~~ ✅
919919+5. ~~**Add DID document**~~ ✅
6719206. **Custom XRPC methods** - getUploadUrl, getDownloadUrl (presigned URLs)
6729217. **Wire up in cmd/hold** - Serve XRPC alongside existing HTTP
6739228. **Test basic operations** - Add/list crew, policy checks
+2-1
gen/main.go
···2020)
21212222func main() {
2323- // Generate map-style encoders for CrewRecord
2323+ // Generate map-style encoders for CrewRecord and CaptainRecord
2424 if err := cbg.WriteMapEncodersToFile("pkg/hold/pds/cbor_gen.go", "pds",
2525 pds.CrewRecord{},
2626+ pds.CaptainRecord{},
2627 ); err != nil {
2728 fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
2829 os.Exit(1)
+194
pkg/hold/pds/auth.go
···11+package pds
22+33+import (
44+ "context"
55+ "encoding/base64"
66+ "encoding/json"
77+ "fmt"
88+ "io"
99+ "net/http"
1010+ "strings"
1111+1212+ "github.com/bluesky-social/indigo/atproto/identity"
1313+ "github.com/bluesky-social/indigo/atproto/syntax"
1414+)
1515+1616+// ValidatedUser represents a successfully validated user from DPoP + OAuth
1717+type ValidatedUser struct {
1818+ DID string
1919+ Handle string
2020+ PDS string
2121+ Authorized bool
2222+}
2323+2424+// ValidateDPoPRequest validates a request with DPoP + OAuth tokens
2525+// This implements the standard ATProto token validation flow:
2626+// 1. Extract Authorization header (DPoP <token>)
2727+// 2. Extract DPoP header (proof JWT)
2828+// 3. Call user's PDS to validate token via com.atproto.server.getSession
2929+// 4. Return validated user DID
3030+func ValidateDPoPRequest(r *http.Request) (*ValidatedUser, error) {
3131+ // Extract Authorization header
3232+ authHeader := r.Header.Get("Authorization")
3333+ if authHeader == "" {
3434+ return nil, fmt.Errorf("missing Authorization header")
3535+ }
3636+3737+ // Check for DPoP authorization scheme
3838+ parts := strings.SplitN(authHeader, " ", 2)
3939+ if len(parts) != 2 {
4040+ return nil, fmt.Errorf("invalid Authorization header format")
4141+ }
4242+4343+ if parts[0] != "DPoP" {
4444+ return nil, fmt.Errorf("expected DPoP authorization scheme, got: %s", parts[0])
4545+ }
4646+4747+ accessToken := parts[1]
4848+ if accessToken == "" {
4949+ return nil, fmt.Errorf("missing access token")
5050+ }
5151+5252+ // Extract DPoP header
5353+ dpopProof := r.Header.Get("DPoP")
5454+ if dpopProof == "" {
5555+ return nil, fmt.Errorf("missing DPoP header")
5656+ }
5757+5858+ // TODO: We could verify the DPoP proof locally (signature, HTM, HTU, etc.)
5959+ // For now, we'll rely on the PDS to validate everything
6060+6161+ // The token contains the user's DID in its claims, but we can't trust it without validation
6262+ // We need to call the user's PDS to validate the token
6363+ // Problem: We don't know which PDS to call yet!
6464+6565+ // For now, we'll parse the JWT to extract the DID/PDS hint (unverified)
6666+ // Then validate against that PDS
6767+ // This is safe because the PDS will verify the token is valid for that DID
6868+6969+ did, pds, err := extractDIDFromToken(accessToken)
7070+ if err != nil {
7171+ return nil, fmt.Errorf("failed to extract DID from token: %w", err)
7272+ }
7373+7474+ // Validate token with the user's PDS
7575+ session, err := validateTokenWithPDS(r.Context(), pds, accessToken, dpopProof)
7676+ if err != nil {
7777+ return nil, fmt.Errorf("token validation failed: %w", err)
7878+ }
7979+8080+ // Verify the DID matches
8181+ if session.DID != did {
8282+ return nil, fmt.Errorf("token DID mismatch: expected %s, got %s", did, session.DID)
8383+ }
8484+8585+ return &ValidatedUser{
8686+ DID: session.DID,
8787+ Handle: session.Handle,
8888+ PDS: pds,
8989+ Authorized: true,
9090+ }, nil
9191+}
9292+9393+// extractDIDFromToken extracts the DID and PDS from an unverified JWT token
9494+// This is just for routing purposes - the token will be validated by the PDS
9595+func extractDIDFromToken(token string) (string, string, error) {
9696+ // JWT format: header.payload.signature
9797+ parts := strings.Split(token, ".")
9898+ if len(parts) != 3 {
9999+ return "", "", fmt.Errorf("invalid JWT format")
100100+ }
101101+102102+ // Decode payload (base64url)
103103+ payload, err := decodeBase64URL(parts[1])
104104+ if err != nil {
105105+ return "", "", fmt.Errorf("failed to decode payload: %w", err)
106106+ }
107107+108108+ // Parse JSON
109109+ var claims struct {
110110+ Sub string `json:"sub"` // DID
111111+ Iss string `json:"iss"` // PDS URL (issuer)
112112+ }
113113+114114+ if err := json.Unmarshal(payload, &claims); err != nil {
115115+ return "", "", fmt.Errorf("failed to parse claims: %w", err)
116116+ }
117117+118118+ if claims.Sub == "" {
119119+ return "", "", fmt.Errorf("missing sub claim (DID)")
120120+ }
121121+122122+ if claims.Iss == "" {
123123+ return "", "", fmt.Errorf("missing iss claim (PDS)")
124124+ }
125125+126126+ return claims.Sub, claims.Iss, nil
127127+}
128128+129129+// decodeBase64URL decodes base64url (RFC 4648)
130130+func decodeBase64URL(s string) ([]byte, error) {
131131+ // Use Go's RawURLEncoding (base64url without padding)
132132+ return base64.RawURLEncoding.DecodeString(s)
133133+}
134134+135135+// SessionResponse represents the response from com.atproto.server.getSession
136136+type SessionResponse struct {
137137+ DID string `json:"did"`
138138+ Handle string `json:"handle"`
139139+}
140140+141141+// validateTokenWithPDS calls the user's PDS to validate the token
142142+func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof string) (*SessionResponse, error) {
143143+ // Call com.atproto.server.getSession with DPoP headers
144144+ url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", strings.TrimSuffix(pdsURL, "/"))
145145+146146+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
147147+ if err != nil {
148148+ return nil, fmt.Errorf("failed to create request: %w", err)
149149+ }
150150+151151+ // Add DPoP authorization headers
152152+ req.Header.Set("Authorization", "DPoP "+accessToken)
153153+ req.Header.Set("DPoP", dpopProof)
154154+155155+ resp, err := http.DefaultClient.Do(req)
156156+ if err != nil {
157157+ return nil, fmt.Errorf("failed to call PDS: %w", err)
158158+ }
159159+ defer resp.Body.Close()
160160+161161+ if resp.StatusCode != http.StatusOK {
162162+ body, _ := io.ReadAll(resp.Body)
163163+ return nil, fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body))
164164+ }
165165+166166+ var session SessionResponse
167167+ if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
168168+ return nil, fmt.Errorf("failed to decode session: %w", err)
169169+ }
170170+171171+ return &session, nil
172172+}
173173+174174+// ResolveDIDToPDS resolves a DID to its PDS endpoint (for reference)
175175+// This is an alternative approach if we don't trust the token's issuer claim
176176+func ResolveDIDToPDS(ctx context.Context, did string) (string, error) {
177177+ directory := identity.DefaultDirectory()
178178+ didParsed, err := syntax.ParseDID(did)
179179+ if err != nil {
180180+ return "", fmt.Errorf("invalid DID: %w", err)
181181+ }
182182+183183+ ident, err := directory.LookupDID(ctx, didParsed)
184184+ if err != nil {
185185+ return "", fmt.Errorf("failed to resolve DID: %w", err)
186186+ }
187187+188188+ pdsEndpoint := ident.PDSEndpoint()
189189+ if pdsEndpoint == "" {
190190+ return "", fmt.Errorf("no PDS endpoint found for DID")
191191+ }
192192+193193+ return pdsEndpoint, nil
194194+}
+146
pkg/hold/pds/captain.go
···11+package pds
22+33+import (
44+ "bytes"
55+ "context"
66+ "fmt"
77+ "time"
88+99+ "github.com/bluesky-social/indigo/repo"
1010+ "github.com/ipfs/go-cid"
1111+)
1212+1313+const (
1414+ // CaptainRkey is the fixed rkey for the captain record (singleton)
1515+ CaptainRkey = "self"
1616+)
1717+1818+// CreateCaptainRecord creates the captain record for the hold
1919+func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) (cid.Cid, error) {
2020+ captainRecord := &CaptainRecord{
2121+ Type: CaptainCollection,
2222+ Owner: ownerDID,
2323+ Public: public,
2424+ AllowAllCrew: allowAllCrew,
2525+ DeployedAt: time.Now().Format(time.RFC3339),
2626+ }
2727+2828+ // Create record in repo with fixed rkey "self"
2929+ recordCID, rkey, err := p.repo.CreateRecord(ctx, CaptainCollection, captainRecord)
3030+ if err != nil {
3131+ return cid.Undef, fmt.Errorf("failed to create captain record: %w", err)
3232+ }
3333+3434+ // Create signer function from signing key
3535+ signer := func(ctx context.Context, did string, data []byte) ([]byte, error) {
3636+ return p.signingKey.HashAndSign(data)
3737+ }
3838+3939+ // Commit the changes to get new root CID
4040+ root, rev, err := p.repo.Commit(ctx, signer)
4141+ if err != nil {
4242+ return cid.Undef, fmt.Errorf("failed to commit captain record: %w", err)
4343+ }
4444+4545+ // Close the delta session with the new root
4646+ _, err = p.session.CloseWithRoot(ctx, root, rev)
4747+ if err != nil {
4848+ return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
4949+ }
5050+5151+ // Create a new session for the next operation
5252+ rootStr := root.String()
5353+ newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
5454+ if err != nil {
5555+ return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
5656+ }
5757+5858+ // Load repo from the newly committed head
5959+ newRepo, err := repo.OpenRepo(ctx, newSession, root)
6060+ if err != nil {
6161+ return cid.Undef, fmt.Errorf("failed to reload repo after commit: %w", err)
6262+ }
6363+6464+ // Update the stored session and repo
6565+ p.session = newSession
6666+ p.repo = newRepo
6767+6868+ fmt.Printf("Created captain record with rkey: %s, cid: %s\n", rkey, recordCID)
6969+7070+ return recordCID, nil
7171+}
7272+7373+// GetCaptainRecord retrieves the captain record
7474+func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *CaptainRecord, error) {
7575+ path := fmt.Sprintf("%s/%s", CaptainCollection, CaptainRkey)
7676+7777+ // Get the record bytes and decode manually
7878+ recordCID, recBytes, err := p.repo.GetRecordBytes(ctx, path)
7979+ if err != nil {
8080+ return cid.Undef, nil, fmt.Errorf("failed to get captain record: %w", err)
8181+ }
8282+8383+ // Decode the CBOR bytes into our CaptainRecord type
8484+ var captainRecord CaptainRecord
8585+ if err := captainRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
8686+ return cid.Undef, nil, fmt.Errorf("failed to decode captain record: %w", err)
8787+ }
8888+8989+ return recordCID, &captainRecord, nil
9090+}
9191+9292+// UpdateCaptainRecord updates the captain record (e.g., to change public/allowAllCrew settings)
9393+func (p *HoldPDS) UpdateCaptainRecord(ctx context.Context, public bool, allowAllCrew bool) (cid.Cid, error) {
9494+ // Get existing record to preserve other fields
9595+ _, existing, err := p.GetCaptainRecord(ctx)
9696+ if err != nil {
9797+ return cid.Undef, fmt.Errorf("failed to get existing captain record: %w", err)
9898+ }
9999+100100+ // Update the fields
101101+ existing.Public = public
102102+ existing.AllowAllCrew = allowAllCrew
103103+104104+ // Update record in repo
105105+ path := fmt.Sprintf("%s/%s", CaptainCollection, CaptainRkey)
106106+ recordCID, err := p.repo.UpdateRecord(ctx, path, existing)
107107+ if err != nil {
108108+ return cid.Undef, fmt.Errorf("failed to update captain record: %w", err)
109109+ }
110110+111111+ // Create signer function from signing key
112112+ signer := func(ctx context.Context, did string, data []byte) ([]byte, error) {
113113+ return p.signingKey.HashAndSign(data)
114114+ }
115115+116116+ // Commit the changes
117117+ root, rev, err := p.repo.Commit(ctx, signer)
118118+ if err != nil {
119119+ return cid.Undef, fmt.Errorf("failed to commit captain record update: %w", err)
120120+ }
121121+122122+ // Close the delta session with the new root
123123+ _, err = p.session.CloseWithRoot(ctx, root, rev)
124124+ if err != nil {
125125+ return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
126126+ }
127127+128128+ // Create a new session for the next operation
129129+ rootStr := root.String()
130130+ newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
131131+ if err != nil {
132132+ return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
133133+ }
134134+135135+ // Load repo from the newly committed head
136136+ newRepo, err := repo.OpenRepo(ctx, newSession, root)
137137+ if err != nil {
138138+ return cid.Undef, fmt.Errorf("failed to reload repo after commit: %w", err)
139139+ }
140140+141141+ // Update the stored session and repo
142142+ p.session = newSession
143143+ p.repo = newRepo
144144+145145+ return recordCID, nil
146146+}
+319
pkg/hold/pds/cbor_gen.go
···293293294294 return nil
295295}
296296+func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
297297+ if t == nil {
298298+ _, err := w.Write(cbg.CborNull)
299299+ return err
300300+ }
301301+302302+ cw := cbg.NewCborWriter(w)
303303+ fieldCount := 7
304304+305305+ if t.Region == "" {
306306+ fieldCount--
307307+ }
308308+309309+ if t.Provider == "" {
310310+ fieldCount--
311311+ }
312312+313313+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
314314+ return err
315315+ }
316316+317317+ // t.Type (string) (string)
318318+ if len("$type") > 8192 {
319319+ return xerrors.Errorf("Value in field \"$type\" was too long")
320320+ }
321321+322322+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
323323+ return err
324324+ }
325325+ if _, err := cw.WriteString(string("$type")); err != nil {
326326+ return err
327327+ }
328328+329329+ if len(t.Type) > 8192 {
330330+ return xerrors.Errorf("Value in field t.Type was too long")
331331+ }
332332+333333+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
334334+ return err
335335+ }
336336+ if _, err := cw.WriteString(string(t.Type)); err != nil {
337337+ return err
338338+ }
339339+340340+ // t.Owner (string) (string)
341341+ if len("owner") > 8192 {
342342+ return xerrors.Errorf("Value in field \"owner\" was too long")
343343+ }
344344+345345+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
346346+ return err
347347+ }
348348+ if _, err := cw.WriteString(string("owner")); err != nil {
349349+ return err
350350+ }
351351+352352+ if len(t.Owner) > 8192 {
353353+ return xerrors.Errorf("Value in field t.Owner was too long")
354354+ }
355355+356356+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil {
357357+ return err
358358+ }
359359+ if _, err := cw.WriteString(string(t.Owner)); err != nil {
360360+ return err
361361+ }
362362+363363+ // t.Public (bool) (bool)
364364+ if len("public") > 8192 {
365365+ return xerrors.Errorf("Value in field \"public\" was too long")
366366+ }
367367+368368+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("public"))); err != nil {
369369+ return err
370370+ }
371371+ if _, err := cw.WriteString(string("public")); err != nil {
372372+ return err
373373+ }
374374+375375+ if err := cbg.WriteBool(w, t.Public); err != nil {
376376+ return err
377377+ }
378378+379379+ // t.Region (string) (string)
380380+ if t.Region != "" {
381381+382382+ if len("region") > 8192 {
383383+ return xerrors.Errorf("Value in field \"region\" was too long")
384384+ }
385385+386386+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("region"))); err != nil {
387387+ return err
388388+ }
389389+ if _, err := cw.WriteString(string("region")); err != nil {
390390+ return err
391391+ }
392392+393393+ if len(t.Region) > 8192 {
394394+ return xerrors.Errorf("Value in field t.Region was too long")
395395+ }
396396+397397+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Region))); err != nil {
398398+ return err
399399+ }
400400+ if _, err := cw.WriteString(string(t.Region)); err != nil {
401401+ return err
402402+ }
403403+ }
404404+405405+ // t.Provider (string) (string)
406406+ if t.Provider != "" {
407407+408408+ if len("provider") > 8192 {
409409+ return xerrors.Errorf("Value in field \"provider\" was too long")
410410+ }
411411+412412+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("provider"))); err != nil {
413413+ return err
414414+ }
415415+ if _, err := cw.WriteString(string("provider")); err != nil {
416416+ return err
417417+ }
418418+419419+ if len(t.Provider) > 8192 {
420420+ return xerrors.Errorf("Value in field t.Provider was too long")
421421+ }
422422+423423+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Provider))); err != nil {
424424+ return err
425425+ }
426426+ if _, err := cw.WriteString(string(t.Provider)); err != nil {
427427+ return err
428428+ }
429429+ }
430430+431431+ // t.DeployedAt (string) (string)
432432+ if len("deployedAt") > 8192 {
433433+ return xerrors.Errorf("Value in field \"deployedAt\" was too long")
434434+ }
435435+436436+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("deployedAt"))); err != nil {
437437+ return err
438438+ }
439439+ if _, err := cw.WriteString(string("deployedAt")); err != nil {
440440+ return err
441441+ }
442442+443443+ if len(t.DeployedAt) > 8192 {
444444+ return xerrors.Errorf("Value in field t.DeployedAt was too long")
445445+ }
446446+447447+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.DeployedAt))); err != nil {
448448+ return err
449449+ }
450450+ if _, err := cw.WriteString(string(t.DeployedAt)); err != nil {
451451+ return err
452452+ }
453453+454454+ // t.AllowAllCrew (bool) (bool)
455455+ if len("allowAllCrew") > 8192 {
456456+ return xerrors.Errorf("Value in field \"allowAllCrew\" was too long")
457457+ }
458458+459459+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("allowAllCrew"))); err != nil {
460460+ return err
461461+ }
462462+ if _, err := cw.WriteString(string("allowAllCrew")); err != nil {
463463+ return err
464464+ }
465465+466466+ if err := cbg.WriteBool(w, t.AllowAllCrew); err != nil {
467467+ return err
468468+ }
469469+ return nil
470470+}
471471+472472+func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
473473+ *t = CaptainRecord{}
474474+475475+ cr := cbg.NewCborReader(r)
476476+477477+ maj, extra, err := cr.ReadHeader()
478478+ if err != nil {
479479+ return err
480480+ }
481481+ defer func() {
482482+ if err == io.EOF {
483483+ err = io.ErrUnexpectedEOF
484484+ }
485485+ }()
486486+487487+ if maj != cbg.MajMap {
488488+ return fmt.Errorf("cbor input should be of type map")
489489+ }
490490+491491+ if extra > cbg.MaxLength {
492492+ return fmt.Errorf("CaptainRecord: map struct too large (%d)", extra)
493493+ }
494494+495495+ n := extra
496496+497497+ nameBuf := make([]byte, 12)
498498+ for i := uint64(0); i < n; i++ {
499499+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
500500+ if err != nil {
501501+ return err
502502+ }
503503+504504+ if !ok {
505505+ // Field doesn't exist on this type, so ignore it
506506+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
507507+ return err
508508+ }
509509+ continue
510510+ }
511511+512512+ switch string(nameBuf[:nameLen]) {
513513+ // t.Type (string) (string)
514514+ case "$type":
515515+516516+ {
517517+ sval, err := cbg.ReadStringWithMax(cr, 8192)
518518+ if err != nil {
519519+ return err
520520+ }
521521+522522+ t.Type = string(sval)
523523+ }
524524+ // t.Owner (string) (string)
525525+ case "owner":
526526+527527+ {
528528+ sval, err := cbg.ReadStringWithMax(cr, 8192)
529529+ if err != nil {
530530+ return err
531531+ }
532532+533533+ t.Owner = string(sval)
534534+ }
535535+ // t.Public (bool) (bool)
536536+ case "public":
537537+538538+ maj, extra, err = cr.ReadHeader()
539539+ if err != nil {
540540+ return err
541541+ }
542542+ if maj != cbg.MajOther {
543543+ return fmt.Errorf("booleans must be major type 7")
544544+ }
545545+ switch extra {
546546+ case 20:
547547+ t.Public = false
548548+ case 21:
549549+ t.Public = true
550550+ default:
551551+ return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
552552+ }
553553+ // t.Region (string) (string)
554554+ case "region":
555555+556556+ {
557557+ sval, err := cbg.ReadStringWithMax(cr, 8192)
558558+ if err != nil {
559559+ return err
560560+ }
561561+562562+ t.Region = string(sval)
563563+ }
564564+ // t.Provider (string) (string)
565565+ case "provider":
566566+567567+ {
568568+ sval, err := cbg.ReadStringWithMax(cr, 8192)
569569+ if err != nil {
570570+ return err
571571+ }
572572+573573+ t.Provider = string(sval)
574574+ }
575575+ // t.DeployedAt (string) (string)
576576+ case "deployedAt":
577577+578578+ {
579579+ sval, err := cbg.ReadStringWithMax(cr, 8192)
580580+ if err != nil {
581581+ return err
582582+ }
583583+584584+ t.DeployedAt = string(sval)
585585+ }
586586+ // t.AllowAllCrew (bool) (bool)
587587+ case "allowAllCrew":
588588+589589+ maj, extra, err = cr.ReadHeader()
590590+ if err != nil {
591591+ return err
592592+ }
593593+ if maj != cbg.MajOther {
594594+ return fmt.Errorf("booleans must be major type 7")
595595+ }
596596+ switch extra {
597597+ case 20:
598598+ t.AllowAllCrew = false
599599+ case 21:
600600+ t.AllowAllCrew = true
601601+ default:
602602+ return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
603603+ }
604604+605605+ default:
606606+ // Field doesn't exist on this type, so ignore it
607607+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
608608+ return err
609609+ }
610610+ }
611611+ }
612612+613613+ return nil
614614+}
···9898 return p.signingKey
9999}
100100101101-// Bootstrap initializes the hold with the owner as the first crew member
102102-func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string) error {
101101+// Bootstrap initializes the hold with the captain record and owner as first crew member
102102+func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) error {
103103 if ownerDID == "" {
104104 return nil
105105 }
···114114 fmt.Printf("⏭️ Skipping PDS bootstrap: repo already initialized (head: %s)\n", head.String()[:16])
115115 return nil
116116 }
117117+118118+ // Create captain record (hold ownership and settings)
119119+ _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew)
120120+ if err != nil {
121121+ return fmt.Errorf("failed to create captain record: %w", err)
122122+ }
123123+124124+ fmt.Printf("✅ Created captain record (public=%v, allowAllCrew=%v)\n", public, allowAllCrew)
117125118126 // Add hold owner as first crew member with admin role
119127 _, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
+17-1
pkg/hold/pds/types.go
···11package pds
2233+//go:generate go run github.com/whyrusleeping/cbor-gen --map-encoding CrewRecord CaptainRecord
44+35// ATProto record types for the hold service
4677+// CaptainRecord represents the hold's ownership and metadata
88+// Collection: io.atcr.hold.captain (single record per hold)
99+type CaptainRecord struct {
1010+ Type string `json:"$type" cborgen:"$type"`
1111+ Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
1212+ Public bool `json:"public" cborgen:"public"` // Public read access
1313+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
1414+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
1515+ Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
1616+ Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
1717+}
1818+519// CrewRecord represents a crew member in the hold
2020+// Collection: io.atcr.hold.crew (one record per member)
621type CrewRecord struct {
722 Type string `json:"$type" cborgen:"$type"`
823 Member string `json:"member" cborgen:"member"`
···1227}
13281429const (
1515- CrewCollection = "io.atcr.hold.crew"
3030+ CaptainCollection = "io.atcr.hold.captain"
3131+ CrewCollection = "io.atcr.hold.crew"
1632)
+103
pkg/hold/pds/xrpc.go
···7979 // DID document and handle resolution
8080 mux.HandleFunc("/.well-known/did.json", corsMiddleware(h.HandleDIDDocument))
8181 mux.HandleFunc("/.well-known/atproto-did", corsMiddleware(h.HandleAtprotoDID))
8282+8383+ // Custom ATCR endpoints
8484+ mux.HandleFunc("/xrpc/io.atcr.hold.requestCrew", corsMiddleware(h.HandleRequestCrew))
8285}
83868487// HandleHealth returns health check information
···480483 w.Header().Set("Content-Type", "text/plain")
481484 fmt.Fprint(w, h.pds.DID())
482485}
486486+487487+// HandleRequestCrew handles crew membership requests
488488+// This endpoint allows authenticated users to request crew membership
489489+// Authorization is checked against captain record settings
490490+func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request) {
491491+ if r.Method != http.MethodPost {
492492+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
493493+ return
494494+ }
495495+496496+ // Validate DPoP + OAuth token from Authorization and DPoP headers
497497+ user, err := ValidateDPoPRequest(r)
498498+ if err != nil {
499499+ http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
500500+ return
501501+ }
502502+503503+ // Parse request body (optional parameters)
504504+ var req struct {
505505+ Role string `json:"role"` // Requested role (default: "member")
506506+ Permissions []string `json:"permissions"` // Requested permissions
507507+ }
508508+509509+ // Body is optional - if empty, just use defaults
510510+ if r.Body != nil && r.ContentLength > 0 {
511511+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
512512+ http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
513513+ return
514514+ }
515515+ }
516516+517517+ // Get captain record to check authorization settings
518518+ _, captain, err := h.pds.GetCaptainRecord(r.Context())
519519+ if err != nil {
520520+ http.Error(w, fmt.Sprintf("failed to get captain record: %v", err), http.StatusInternalServerError)
521521+ return
522522+ }
523523+524524+ // Check authorization:
525525+ // 1. If allowAllCrew is true, any authenticated user can join
526526+ // 2. If user is the owner, they can always join (though they should already be crew)
527527+ // 3. Otherwise, deny
528528+ isOwner := user.DID == captain.Owner
529529+ if !captain.AllowAllCrew && !isOwner {
530530+ http.Error(w, "crew registration not allowed (HOLD_ALLOW_ALL_CREW=false)", http.StatusForbidden)
531531+ return
532532+ }
533533+534534+ // Set defaults if not provided
535535+ if req.Role == "" {
536536+ req.Role = "member"
537537+ }
538538+ if len(req.Permissions) == 0 {
539539+ req.Permissions = []string{"blob:read", "blob:write"}
540540+ }
541541+542542+ // Check if user is already a crew member
543543+ // List all crew members and check if this DID is already present
544544+ crew, err := h.pds.ListCrewMembers(r.Context())
545545+ if err != nil {
546546+ http.Error(w, fmt.Sprintf("failed to list crew members: %v", err), http.StatusInternalServerError)
547547+ return
548548+ }
549549+550550+ for _, member := range crew {
551551+ if member.Record.Member == user.DID {
552552+ // Already a crew member, return success with existing record
553553+ response := map[string]any{
554554+ "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), CrewCollection, member.Rkey),
555555+ "cid": member.Cid.String(),
556556+ "status": "already_member",
557557+ "message": "User is already a crew member",
558558+ }
559559+ w.Header().Set("Content-Type", "application/json")
560560+ w.WriteHeader(http.StatusOK)
561561+ json.NewEncoder(w).Encode(response)
562562+ return
563563+ }
564564+ }
565565+566566+ // Create new crew record
567567+ recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions)
568568+ if err != nil {
569569+ http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError)
570570+ return
571571+ }
572572+573573+ // Return success response
574574+ // Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it
575575+ // For now, return just the CID. In production, AddCrewMember should return both CID and rkey
576576+ response := map[string]any{
577577+ "cid": recordCID.String(),
578578+ "status": "created",
579579+ "message": "Successfully added to crew",
580580+ }
581581+582582+ w.Header().Set("Content-Type", "application/json")
583583+ w.WriteHeader(http.StatusCreated)
584584+ json.NewEncoder(w).Encode(response)
585585+}
-481
pkg/hold/registration.go
···11-package hold
22-33-import (
44- "context"
55- "encoding/json"
66- "errors"
77- "fmt"
88- "log"
99- "net/http"
1010- "net/url"
1111- "strings"
1212- "time"
1313-1414- "atcr.io/pkg/atproto"
1515- "atcr.io/pkg/auth/oauth"
1616- "github.com/bluesky-social/indigo/atproto/identity"
1717- "github.com/bluesky-social/indigo/atproto/syntax"
1818-)
1919-2020-// HealthHandler handles health check requests
2121-func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
2222- w.Header().Set("Content-Type", "application/json")
2323- w.Write([]byte(`{"status":"ok"}`))
2424-}
2525-2626-// isHoldRegistered checks if a hold with the given public URL is already registered in the PDS
2727-func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) {
2828- // We need to query the PDS without authentication to check public records
2929- // ATProto records are publicly readable, so we can use an unauthenticated client
3030- client := atproto.NewClient(pdsEndpoint, did, "")
3131-3232- // List all hold records for this DID
3333- records, err := client.ListRecords(ctx, atproto.HoldCollection, 100)
3434- if err != nil {
3535- return false, fmt.Errorf("failed to list hold records: %w", err)
3636- }
3737-3838- // Check if any hold record matches our public URL
3939- for _, record := range records {
4040- var holdRecord atproto.HoldRecord
4141- if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
4242- continue
4343- }
4444-4545- if holdRecord.Endpoint == publicURL {
4646- return true, nil
4747- }
4848- }
4949-5050- return false, nil
5151-}
5252-5353-// AutoRegister registers this hold service in the owner's PDS
5454-// Checks if already registered first, then does OAuth if needed
5555-func (s *HoldService) AutoRegister(callbackHandler *http.HandlerFunc) error {
5656- reg := &s.config.Registration
5757- publicURL := s.config.Server.PublicURL
5858-5959- if publicURL == "" {
6060- return fmt.Errorf("HOLD_PUBLIC_URL not set")
6161- }
6262-6363- if reg.OwnerDID == "" {
6464- return fmt.Errorf("HOLD_OWNER not set - required for registration")
6565- }
6666-6767- ctx := context.Background()
6868-6969- log.Printf("Checking registration status for DID: %s", reg.OwnerDID)
7070-7171- // Resolve DID to PDS endpoint using indigo
7272- directory := identity.DefaultDirectory()
7373- didParsed, err := syntax.ParseDID(reg.OwnerDID)
7474- if err != nil {
7575- return fmt.Errorf("invalid owner DID: %w", err)
7676- }
7777-7878- ident, err := directory.LookupDID(ctx, didParsed)
7979- if err != nil {
8080- return fmt.Errorf("failed to resolve PDS for DID: %w", err)
8181- }
8282-8383- pdsEndpoint := ident.PDSEndpoint()
8484- if pdsEndpoint == "" {
8585- return fmt.Errorf("no PDS endpoint found for DID")
8686- }
8787-8888- log.Printf("PDS endpoint: %s", pdsEndpoint)
8989-9090- // Check if hold is already registered
9191- isRegistered, err := s.isHoldRegistered(ctx, reg.OwnerDID, pdsEndpoint, publicURL)
9292- if err != nil {
9393- log.Printf("Warning: failed to check registration status: %v", err)
9494- log.Printf("Proceeding with OAuth registration...")
9595- } else if isRegistered {
9696- log.Printf("✓ Hold service already registered in PDS")
9797- log.Printf("Public URL: %s", publicURL)
9898- return nil
9999- }
100100-101101- // Not registered, need to do OAuth
102102- log.Printf("Hold not registered, starting OAuth flow...")
103103-104104- // Get handle from DID document (already resolved above)
105105- handle := ident.Handle.String()
106106- if handle == "" || handle == "handle.invalid" {
107107- return fmt.Errorf("no valid handle found for DID")
108108- }
109109-110110- log.Printf("Resolved handle: %s", handle)
111111- log.Printf("Starting OAuth registration for hold service")
112112- log.Printf("Public URL: %s", publicURL)
113113-114114- return s.registerWithOAuth(publicURL, handle, reg.OwnerDID, pdsEndpoint, callbackHandler)
115115-}
116116-117117-// registerWithOAuth performs OAuth flow and registers the hold
118118-func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string, callbackHandler *http.HandlerFunc) error {
119119- // Run OAuth flow to get authenticated client
120120- client, err := s.runOAuthFlow(callbackHandler, "Hold service registration")
121121- if err != nil {
122122- return err
123123- }
124124-125125- log.Printf("Authorization received!")
126126- log.Printf("OAuth session obtained successfully")
127127- log.Printf("DID: %s", did)
128128- log.Printf("PDS: %s", pdsEndpoint)
129129-130130- return s.registerWithClient(publicURL, did, client)
131131-}
132132-133133-// registerWithClient registers the hold using an authenticated ATProto client
134134-func (s *HoldService) registerWithClient(publicURL, did string, client *atproto.Client) error {
135135- // Derive hold name from URL (hostname)
136136- holdName, err := extractHostname(publicURL)
137137- if err != nil {
138138- return fmt.Errorf("failed to extract hostname from URL: %w", err)
139139- }
140140-141141- log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did)
142142-143143- ctx := context.Background()
144144-145145- // Create HoldRecord
146146- holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public)
147147-148148- // Use hostname as record key
149149- holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord)
150150- if err != nil {
151151- return fmt.Errorf("failed to create hold record: %w", err)
152152- }
153153-154154- log.Printf("✓ Created hold record: %s", holdResult.URI)
155155-156156- // Create HoldCrewRecord for the owner
157157- crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, did, "owner")
158158-159159- crewRKey := fmt.Sprintf("%s-%s", holdName, did)
160160- crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
161161- if err != nil {
162162- return fmt.Errorf("failed to create crew record: %w", err)
163163- }
164164-165165- log.Printf("✓ Created crew record: %s", crewResult.URI)
166166-167167- // Update sailor profile to set this as the default hold
168168- profile, err := atproto.GetProfile(ctx, client)
169169- if err != nil {
170170- log.Printf("Warning: failed to get sailor profile: %v", err)
171171- } else {
172172- if profile == nil {
173173- // Create new profile with this hold as default
174174- profile = atproto.NewSailorProfileRecord(publicURL)
175175- } else {
176176- // Update existing profile with new defaultHold
177177- profile.DefaultHold = publicURL
178178- profile.UpdatedAt = time.Now()
179179- }
180180-181181- err = atproto.UpdateProfile(ctx, client, profile)
182182- if err != nil {
183183- log.Printf("Warning: failed to update sailor profile: %v", err)
184184- } else {
185185- log.Printf("✓ Updated sailor profile defaultHold: %s", publicURL)
186186- }
187187- }
188188-189189- log.Print("\n" + strings.Repeat("=", 80))
190190- log.Printf("REGISTRATION COMPLETE")
191191- log.Print(strings.Repeat("=", 80))
192192- log.Printf("Hold service is now registered and ready to use!")
193193- log.Print(strings.Repeat("=", 80) + "\n")
194194-195195- return nil
196196-}
197197-198198-// extractHostname extracts the hostname from a URL to use as the hold name
199199-func extractHostname(urlStr string) (string, error) {
200200- u, err := url.Parse(urlStr)
201201- if err != nil {
202202- return "", err
203203- }
204204- // Remove port if present
205205- hostname := u.Hostname()
206206- if hostname == "" {
207207- return "", fmt.Errorf("no hostname in URL")
208208- }
209209- return hostname, nil
210210-}
211211-212212-// ReconcileAllowAllCrew reconciles the allow-all crew record state with the environment variable
213213-// Called on every startup to ensure the PDS record matches the desired configuration
214214-func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error {
215215- ownerDID := s.config.Registration.OwnerDID
216216- if ownerDID == "" {
217217- // No owner DID configured, skip reconciliation
218218- return nil
219219- }
220220-221221- desiredState := s.config.Registration.AllowAllCrew
222222-223223- log.Printf("Checking allow-all crew state (desired: %v)", desiredState)
224224-225225- // Query PDS for current state
226226- actualState, err := s.hasAllowAllCrewRecord()
227227- if err != nil {
228228- return fmt.Errorf("failed to check allow-all crew record: %w", err)
229229- }
230230-231231- log.Printf("Allow-all crew record exists: %v", actualState)
232232-233233- // States match - nothing to do
234234- if desiredState == actualState {
235235- if desiredState {
236236- log.Printf("✓ Allow-all crew enabled (all authenticated users can push)")
237237- } else {
238238- log.Printf("✓ Allow-all crew disabled (explicit crew membership required)")
239239- }
240240- return nil
241241- }
242242-243243- // State mismatch - need to reconcile
244244- if desiredState && !actualState {
245245- // Need to create wildcard crew record
246246- log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)")
247247- return s.createAllowAllCrewRecord(callbackHandler)
248248- }
249249-250250- if !desiredState && actualState {
251251- // Need to delete wildcard crew record
252252- log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW=false)")
253253- return s.deleteAllowAllCrewRecord(callbackHandler)
254254- }
255255-256256- return nil
257257-}
258258-259259-// hasAllowAllCrewRecord checks if the allow-all crew record exists in the PDS for THIS hold
260260-func (s *HoldService) hasAllowAllCrewRecord() (bool, error) {
261261- ownerDID := s.config.Registration.OwnerDID
262262- publicURL := s.config.Server.PublicURL
263263- if ownerDID == "" {
264264- return false, fmt.Errorf("hold owner DID not configured")
265265- }
266266- if publicURL == "" {
267267- return false, fmt.Errorf("hold public URL not configured")
268268- }
269269-270270- ctx := context.Background()
271271-272272- // Resolve owner's PDS endpoint
273273- directory := identity.DefaultDirectory()
274274- ownerDIDParsed, err := syntax.ParseDID(ownerDID)
275275- if err != nil {
276276- return false, fmt.Errorf("invalid owner DID: %w", err)
277277- }
278278-279279- ident, err := directory.LookupDID(ctx, ownerDIDParsed)
280280- if err != nil {
281281- return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
282282- }
283283-284284- pdsEndpoint := ident.PDSEndpoint()
285285- if pdsEndpoint == "" {
286286- return false, fmt.Errorf("no PDS endpoint found for owner")
287287- }
288288-289289- // Build hold-specific rkey
290290- holdName, err := extractHostname(publicURL)
291291- if err != nil {
292292- return false, fmt.Errorf("failed to extract hostname: %w", err)
293293- }
294294- crewRKey := fmt.Sprintf("allow-all-%s", holdName)
295295-296296- // Create unauthenticated client to read public records
297297- client := atproto.NewClient(pdsEndpoint, ownerDID, "")
298298-299299- // Query for hold-specific allow-all record
300300- record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, crewRKey)
301301- if err != nil {
302302- // Record doesn't exist
303303- if errors.Is(err, atproto.ErrRecordNotFound) {
304304- return false, nil
305305- }
306306- return false, fmt.Errorf("failed to get crew record: %w", err)
307307- }
308308-309309- // Verify it's the wildcard record (memberPattern: "*")
310310- var crewRecord atproto.HoldCrewRecord
311311- if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
312312- return false, fmt.Errorf("failed to unmarshal crew record: %w", err)
313313- }
314314-315315- // Check if it's the exact wildcard pattern
316316- if crewRecord.MemberPattern == nil || *crewRecord.MemberPattern != "*" {
317317- return false, nil
318318- }
319319-320320- // Verify it's for this hold (defensive check)
321321- expectedHoldURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
322322- return crewRecord.Hold == expectedHoldURI, nil
323323-}
324324-325325-// createAllowAllCrewRecord creates a wildcard crew record allowing all authenticated users
326326-func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
327327- ownerDID := s.config.Registration.OwnerDID
328328- publicURL := s.config.Server.PublicURL
329329-330330- // Run OAuth flow to get authenticated client
331331- client, err := s.runOAuthFlow(callbackHandler, "Creating allow-all crew record")
332332- if err != nil {
333333- return err
334334- }
335335-336336- ctx := context.Background()
337337-338338- // Get hold URI
339339- holdName, err := extractHostname(publicURL)
340340- if err != nil {
341341- return fmt.Errorf("failed to extract hostname: %w", err)
342342- }
343343-344344- holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
345345-346346- // Create wildcard crew record
347347- crewRecord := atproto.NewHoldCrewRecordWithPattern(holdURI, "*", "write")
348348-349349- // Use hold-specific rkey to support multiple holds with different allow-all settings
350350- crewRKey := fmt.Sprintf("allow-all-%s", holdName)
351351- _, err = client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
352352- if err != nil {
353353- return fmt.Errorf("failed to create allow-all crew record: %w", err)
354354- }
355355-356356- log.Printf("✓ Created allow-all crew record (allows all authenticated users)")
357357- return nil
358358-}
359359-360360-// deleteAllowAllCrewRecord deletes the wildcard crew record for this hold
361361-func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
362362- // Safety check: only delete if it's the exact wildcard pattern for THIS hold
363363- isWildcard, err := s.hasAllowAllCrewRecord()
364364- if err != nil {
365365- return fmt.Errorf("failed to check allow-all crew record: %w", err)
366366- }
367367-368368- if !isWildcard {
369369- log.Printf("Note: 'allow-all' crew record not found for this hold (may exist for other holds)")
370370- return nil
371371- }
372372-373373- // Get hold name for rkey
374374- holdName, err := extractHostname(s.config.Server.PublicURL)
375375- if err != nil {
376376- return fmt.Errorf("failed to extract hostname: %w", err)
377377- }
378378- crewRKey := fmt.Sprintf("allow-all-%s", holdName)
379379-380380- // Run OAuth flow to get authenticated client
381381- client, err := s.runOAuthFlow(callbackHandler, "Deleting allow-all crew record")
382382- if err != nil {
383383- return err
384384- }
385385-386386- ctx := context.Background()
387387-388388- // Delete the hold-specific allow-all record
389389- err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, crewRKey)
390390- if err != nil {
391391- return fmt.Errorf("failed to delete allow-all crew record: %w", err)
392392- }
393393-394394- log.Printf("✓ Deleted allow-all crew record for this hold")
395395- return nil
396396-}
397397-398398-// getHoldRegistrationScopes returns the OAuth scopes needed for hold registration and crew management
399399-func getHoldRegistrationScopes() []string {
400400- return []string{
401401- "atproto",
402402- fmt.Sprintf("repo:%s", atproto.HoldCollection),
403403- fmt.Sprintf("repo:%s", atproto.HoldCrewCollection),
404404- fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
405405- }
406406-}
407407-408408-// runOAuthFlow performs OAuth flow and returns an authenticated client
409409-// Reusable helper to avoid code duplication across registration and reconciliation
410410-func (s *HoldService) runOAuthFlow(callbackHandler *http.HandlerFunc, purpose string) (*atproto.Client, error) {
411411- ownerDID := s.config.Registration.OwnerDID
412412- publicURL := s.config.Server.PublicURL
413413-414414- ctx := context.Background()
415415-416416- // Resolve owner's PDS endpoint
417417- directory := identity.DefaultDirectory()
418418- ownerDIDParsed, err := syntax.ParseDID(ownerDID)
419419- if err != nil {
420420- return nil, fmt.Errorf("invalid owner DID: %w", err)
421421- }
422422-423423- ident, err := directory.LookupDID(ctx, ownerDIDParsed)
424424- if err != nil {
425425- return nil, fmt.Errorf("failed to resolve owner PDS: %w", err)
426426- }
427427-428428- pdsEndpoint := ident.PDSEndpoint()
429429- if pdsEndpoint == "" {
430430- return nil, fmt.Errorf("no PDS endpoint found for owner")
431431- }
432432-433433- handle := ident.Handle.String()
434434- if handle == "" || handle == "handle.invalid" {
435435- return nil, fmt.Errorf("no valid handle found for DID")
436436- }
437437-438438- // Determine base URL for OAuth
439439- var baseURL string
440440- if s.config.Server.TestMode {
441441- parsedURL, err := url.Parse(publicURL)
442442- if err != nil {
443443- return nil, fmt.Errorf("failed to parse public URL: %w", err)
444444- }
445445- port := parsedURL.Port()
446446- if port == "" {
447447- port = "8080"
448448- }
449449- baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
450450- } else {
451451- baseURL = publicURL
452452- }
453453-454454- // Run OAuth flow
455455- result, err := oauth.InteractiveFlowWithCallback(
456456- ctx,
457457- baseURL,
458458- handle,
459459- getHoldRegistrationScopes(),
460460- func(handler http.HandlerFunc) error {
461461- *callbackHandler = handler
462462- return nil
463463- },
464464- func(authURL string) error {
465465- log.Print("\n" + strings.Repeat("=", 80))
466466- log.Printf("OAUTH REQUIRED: %s", purpose)
467467- log.Print(strings.Repeat("=", 80))
468468- log.Printf("\nVisit: %s\n", authURL)
469469- log.Printf("Waiting for authorization...")
470470- log.Print(strings.Repeat("=", 80) + "\n")
471471- return nil
472472- },
473473- )
474474- if err != nil {
475475- return nil, fmt.Errorf("OAuth flow failed: %w", err)
476476- }
477477-478478- // Create authenticated client
479479- apiClient := result.Session.APIClient()
480480- return atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient), nil
481481-}
+22
pkg/hold/service.go
···44 "context"
55 "fmt"
66 "log"
77+ "net/http"
88+ "net/url"
79810 "github.com/aws/aws-sdk-go/service/s3"
911 storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
···4749func (s *HoldService) GetPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
4850 return s.getPresignedURL(ctx, operation, digest, did)
4951}
5252+5353+// HealthHandler handles health check requests
5454+func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
5555+ w.Header().Set("Content-Type", "application/json")
5656+ w.Write([]byte(`{"status":"ok"}`))
5757+}
5858+5959+// extractHostname extracts the hostname from a URL
6060+func extractHostname(urlStr string) (string, error) {
6161+ u, err := url.Parse(urlStr)
6262+ if err != nil {
6363+ return "", err
6464+ }
6565+ // Remove port if present
6666+ hostname := u.Hostname()
6767+ if hostname == "" {
6868+ return "", fmt.Errorf("no hostname in URL")
6969+ }
7070+ return hostname, nil
7171+}