···10101111```bash
1212# Build all binaries
1313-go build -o atcr-registry ./cmd/registry
1414-go build -o atcr-hold ./cmd/hold
1515-go build -o docker-credential-atcr ./cmd/credential-helper
1313+# create go builds in the bin/ directory
1414+go build -o bin/atcr-registry ./cmd/registry
1515+go build -o bin/atcr-hold ./cmd/hold
1616+go build -o bin/docker-credential-atcr ./cmd/credential-helper
16171718# Run tests
1819go test ./...
···269270**Architecture:**
270271- Reuses distribution's storage driver factory
271272- Supports all distribution drivers: S3, Storj, Minio, Azure, GCS, filesystem
272272-- Authorization based on PDS records (hold.public field, crew records)
273273+- Authorization follows ATProto's public-by-default model
273274- Generates presigned URLs (15min expiry) or proxies uploads/downloads
275275+276276+**Authorization Model:**
277277+278278+Read access:
279279+- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users
280280+- **Private hold** (`HOLD_PUBLIC=false`): Authenticated users only (any ATCR user)
281281+282282+Write access:
283283+- Hold owner OR crew members only
284284+- Verified via `io.atcr.hold.crew` records in owner's PDS
285285+286286+Key insight: "Private" gates anonymous access, not authenticated access. This reflects ATProto's current limitation (no private PDS records yet).
274287275288**Endpoints:**
276289- `POST /get-presigned-url` - Get download URL for blob
+1-1
SAILOR.md
···90909191 ⏳ In Progress:
9292 - Need to update /auth/token handler similarly (add defaultHoldEndpoint parameter and profile management)
9393- - Fix compilation error in extractDefaultHoldEndpoint() - should use configuration.Middleware type not interface{}
9393+ - Fix compilation error in extractDefaultHoldEndpoint() - should use configuration.Middleware type not any
94949595 🔜 Remaining:
9696 - Update findStorageEndpoint() for new priority logic (check profile → own hold → default)
···121121 return
122122 }
123123124124- // Validate DID authorization
125125- if !s.isAuthorized(req.DID) {
126126- http.Error(w, "forbidden: DID not authorized", http.StatusForbidden)
124124+ // Validate DID authorization for READ
125125+ if !s.isAuthorizedRead(req.DID) {
126126+ if req.DID == "" {
127127+ // Anonymous request to private hold
128128+ http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
129129+ } else {
130130+ // Authenticated but not authorized
131131+ http.Error(w, "forbidden: access denied", http.StatusForbidden)
132132+ }
127133 return
128134 }
129135···161167 return
162168 }
163169164164- // Validate DID authorization
165165- if !s.isAuthorized(req.DID) {
166166- http.Error(w, "forbidden: DID not authorized", http.StatusForbidden)
170170+ // Validate DID authorization for WRITE
171171+ if !s.isAuthorizedWrite(req.DID) {
172172+ if req.DID == "" {
173173+ // Anonymous write attempt
174174+ http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
175175+ } else {
176176+ // Authenticated but not crew/owner
177177+ http.Error(w, "forbidden: write access denied", http.StatusForbidden)
178178+ }
167179 return
168180 }
169181···206218 did = r.Header.Get("X-ATCR-DID")
207219 }
208220209209- if !s.isAuthorized(did) {
210210- http.Error(w, "forbidden", http.StatusForbidden)
221221+ // Authorize READ access
222222+ if !s.isAuthorizedRead(did) {
223223+ if did == "" {
224224+ http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
225225+ } else {
226226+ http.Error(w, "forbidden: access denied", http.StatusForbidden)
227227+ }
211228 return
212229 }
213230···243260 did = r.Header.Get("X-ATCR-DID")
244261 }
245262246246- if !s.isAuthorized(did) {
247247- http.Error(w, "forbidden", http.StatusForbidden)
263263+ // Authorize WRITE access
264264+ if !s.isAuthorizedWrite(did) {
265265+ if did == "" {
266266+ http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
267267+ } else {
268268+ http.Error(w, "forbidden: write access denied", http.StatusForbidden)
269269+ }
248270 return
249271 }
250272···266288 w.WriteHeader(http.StatusCreated)
267289}
268290269269-// isAuthorized checks if a DID is authorized to use this hold
270270-// Authorization is now based on:
271271-// - Hold record's "public" field (for reads)
272272-// - Crew records in PDS (for writes)
273273-// TODO: Query PDS to check hold.public and crew membership
274274-func (s *HoldService) isAuthorized(did string) bool {
275275- // For now, allow all requests
276276- // Real implementation should query PDS for hold record and crew records
291291+// isAuthorizedRead checks if a DID can read from this hold
292292+// Authorization:
293293+// - Public hold: allow anonymous (empty DID) or any authenticated user
294294+// - Private hold: require authentication (any user with sailor.profile)
295295+func (s *HoldService) isAuthorizedRead(did string) bool {
296296+ // Check hold public flag
297297+ isPublic, err := s.isHoldPublic()
298298+ if err != nil {
299299+ log.Printf("ERROR: Failed to check hold public flag: %v", err)
300300+ // Fail secure - deny access on error
301301+ return false
302302+ }
303303+304304+ if isPublic {
305305+ // Public hold - allow anyone (even anonymous)
306306+ return true
307307+ }
308308+309309+ // Private hold - require authentication
310310+ // Any authenticated user with sailor.profile can read
311311+ if did == "" {
312312+ // Anonymous user trying to access private hold
313313+ return false
314314+ }
315315+316316+ // For MVP: assume DID presence means they have sailor.profile
317317+ // Future: could query PDS to verify sailor.profile exists
277318 return true
278319}
279320321321+// isAuthorizedWrite checks if a DID can write to this hold
322322+// Authorization: must be hold owner OR crew member
323323+func (s *HoldService) isAuthorizedWrite(did string) bool {
324324+ if did == "" {
325325+ // Anonymous writes not allowed
326326+ return false
327327+ }
328328+329329+ // Check if DID is the hold owner
330330+ ownerDID := s.config.Registration.OwnerDID
331331+ if ownerDID == "" {
332332+ log.Printf("ERROR: Hold owner DID not configured")
333333+ return false
334334+ }
335335+336336+ if did == ownerDID {
337337+ // Owner always has write access
338338+ return true
339339+ }
340340+341341+ // Check if DID is a crew member
342342+ isCrew, err := s.isCrewMember(did)
343343+ if err != nil {
344344+ log.Printf("ERROR: Failed to check crew membership: %v", err)
345345+ return false
346346+ }
347347+348348+ return isCrew
349349+}
350350+351351+// isHoldPublic checks if this hold allows public (anonymous) reads
352352+func (s *HoldService) isHoldPublic() (bool, error) {
353353+ // Use cached config value for now
354354+ // Future: could query PDS for hold record to get live value
355355+ return s.config.Server.Public, nil
356356+}
357357+358358+// isCrewMember checks if a DID is a crew member of this hold
359359+func (s *HoldService) isCrewMember(did string) (bool, error) {
360360+ ownerDID := s.config.Registration.OwnerDID
361361+ if ownerDID == "" {
362362+ return false, fmt.Errorf("hold owner DID not configured")
363363+ }
364364+365365+ ctx := context.Background()
366366+367367+ // Resolve owner's PDS endpoint
368368+ resolver := atproto.NewResolver()
369369+ pdsEndpoint, err := resolver.ResolvePDS(ctx, ownerDID)
370370+ if err != nil {
371371+ return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
372372+ }
373373+374374+ // Create unauthenticated client to read public records
375375+ client := atproto.NewClient(pdsEndpoint, ownerDID, "")
376376+377377+ // List crew records for this hold
378378+ // Crew records are public, so we can read them without auth
379379+ records, err := client.ListRecords(ctx, atproto.HoldCrewCollection, 100)
380380+ if err != nil {
381381+ return false, fmt.Errorf("failed to list crew records: %w", err)
382382+ }
383383+384384+ // Check if DID is in crew list
385385+ for _, record := range records {
386386+ var crewRecord atproto.HoldCrewRecord
387387+ if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
388388+ continue
389389+ }
390390+391391+ if crewRecord.Member == did {
392392+ // Found crew membership
393393+ return true, nil
394394+ }
395395+ }
396396+397397+ return false, nil
398398+}
399399+280400// getDownloadURL generates a download URL for a blob
281401func (s *HoldService) getDownloadURL(ctx context.Context, digest string) (string, error) {
282402 // Check if blob exists
···480600481601// buildStorageConfig creates storage configuration based on driver type
482602func buildStorageConfig(driver string) (StorageConfig, error) {
483483- params := make(map[string]interface{})
603603+ params := make(map[string]any)
484604485605 switch driver {
486606 case "s3":
···634754 }
635755636756 // Print the OAuth URL for user to visit
637637- log.Printf("\n" + strings.Repeat("=", 80))
757757+ log.Print("\n" + strings.Repeat("=", 80))
638758 log.Printf("OAUTH AUTHORIZATION REQUIRED")
639639- log.Printf(strings.Repeat("=", 80))
759759+ log.Print(strings.Repeat("=", 80))
640760 log.Printf("\nPlease visit this URL to authorize the hold service:\n")
641761 log.Printf(" %s\n", authURL)
642762 log.Printf("Waiting for authorization...")
643643- log.Printf(strings.Repeat("=", 80) + "\n")
763763+ log.Print(strings.Repeat("=", 80) + "\n")
644764645765 // Start temporary HTTP server for callback
646766 codeChan := make(chan string, 1)
···746866747867 log.Printf("✓ Created crew record: %s", crewResult.URI)
748868749749- log.Printf("\n" + strings.Repeat("=", 80))
869869+ log.Print("\n" + strings.Repeat("=", 80))
750870 log.Printf("REGISTRATION COMPLETE")
751751- log.Printf(strings.Repeat("=", 80))
871871+ log.Print(strings.Repeat("=", 80))
752872 log.Printf("Hold service is now registered and ready to use!")
753753- log.Printf(strings.Repeat("=", 80) + "\n")
873873+ log.Print(strings.Repeat("=", 80) + "\n")
754874755875 return nil
756876}
+1-1
cmd/registry/serve.go
···205205 continue
206206 }
207207208208- // Extract options - options is configuration.Parameters which is map[string]interface{}
208208+ // Extract options - options is configuration.Parameters which is map[string]any
209209 if mw.Options != nil {
210210 if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok {
211211 return endpoint
credential-helper
This is a binary file and will not be displayed.
+50-15
docs/BYOS.md
···133133```
134134135135**Authorization:**
136136-- Authorization is now based on PDS records, not local config
137137-- Public reads: controlled by `HOLD_PUBLIC` env var (stored in hold record)
138138-- Writes: controlled by `io.atcr.hold.crew` records in PDS
136136+137137+ATCR follows ATProto's public-by-default model with gated anonymous access:
138138+139139+**Read Access:**
140140+- **Public hold** (`HOLD_PUBLIC=true`): Anonymous reads allowed (no authentication)
141141+- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication (any ATCR user with sailor.profile)
142142+143143+**Write Access:**
144144+- Always requires authentication
145145+- Must be hold owner OR crew member (verified via `io.atcr.hold.crew` records in owner's PDS)
146146+147147+**Key Points:**
148148+- "Private" just means "no anonymous access" - not "limited user access"
149149+- Any authenticated ATCR user can read from private holds
150150+- Crew membership only controls WRITE access, not READ access
151151+- This aligns with ATProto's public records model (no private PDS records yet)
139152140153### Running
141154···337350338351### Authorization
339352340340-Authorization is now based on ATProto PDS records:
353353+Authorization is based on ATProto's public-by-default model:
341354342342-- **Public reads**: Controlled by `hold.public` field in hold record (set via `HOLD_PUBLIC` env var)
343343-- **Writes**: Controlled by `io.atcr.hold.crew` records in PDS
344344-- **Owner**: User who created the hold record automatically gets crew owner role
345345-- **No local config**: Authorization state lives in PDS, not hold service config
355355+**Read Authorization:**
356356+- **Public hold** (`public: true` in hold record):
357357+ - Anonymous users: ✅ Allowed
358358+ - Any authenticated user: ✅ Allowed
359359+360360+- **Private hold** (`public: false` in hold record):
361361+ - Anonymous users: ❌ 401 Unauthorized
362362+ - Any authenticated ATCR user: ✅ Allowed (no crew membership required)
346363347347-The hold service queries the PDS to check:
348348-1. Hold record's `public` field for read authorization
349349-2. Crew records for write authorization
364364+**Write Authorization:**
365365+- Anonymous users: ❌ 401 Unauthorized
366366+- Authenticated non-crew: ❌ 403 Forbidden
367367+- Authenticated crew member: ✅ Allowed
368368+- Hold owner: ✅ Allowed
369369+370370+**Implementation:**
371371+- Hold service queries owner's PDS for `io.atcr.hold.crew` records
372372+- Crew records are public ATProto records (read without authentication)
373373+- "Private" holds only gate anonymous access, not authenticated user access
374374+- This reflects ATProto's current limitation: no private PDS records
350375351376### Presigned URLs
352377···356381357382### Private Holds
358383359359-Users can restrict access by:
360360-1. Setting `HOLD_PUBLIC=false` (requires authentication for all operations)
361361-2. Adding crew members via `io.atcr.hold.crew` records in PDS
384384+"Private" holds gate anonymous access while remaining accessible to authenticated users:
385385+386386+**What "Private" Means:**
387387+- `HOLD_PUBLIC=false` prevents anonymous reads
388388+- Any authenticated ATCR user can still read
389389+- This aligns with ATProto's public records model
390390+391391+**Write Control:**
392392+- Only hold owner and crew members can write
393393+- Crew membership managed via `io.atcr.hold.crew` records in owner's PDS
394394+- Removing crew member immediately revokes write access
362395363363-Only users with crew records can write to the hold.
396396+**Future: True Private Access**
397397+- When ATProto adds private PDS records, ATCR can support truly private repos
398398+- For now, "private" = "authenticated-only access"
364399365400## Example: Personal Storage
366401
+3-3
lexicons/io/atcr/hold/crew.json
···44 "defs": {
55 "main": {
66 "type": "record",
77- "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over access. Defines who can use a specific hold.",
77+ "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Crew members can push blobs to the hold. Read access is controlled by the hold's public flag, not crew membership.",
88 "key": "any",
99 "record": {
1010 "type": "object",
···2222 },
2323 "role": {
2424 "type": "string",
2525- "description": "Member's role/permissions",
2626- "knownValues": ["owner", "write", "read"]
2525+ "description": "Member's role/permissions for write access. 'owner' = hold owner, 'write' = can push blobs. Read access is controlled by hold's public flag.",
2626+ "knownValues": ["owner", "write"]
2727 },
2828 "expiresAt": {
2929 "type": "string",
+3-3
pkg/atproto/client.go
···3535}
36363737// PutRecord stores a record in the ATProto repository
3838-func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record interface{}) (*Record, error) {
3838+func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (*Record, error) {
3939 // Construct the record URI
4040 // Format: at://<did>/<collection>/<rkey>
41414242- payload := map[string]interface{}{
4242+ payload := map[string]any{
4343 "repo": c.did,
4444 "collection": collection,
4545 "rkey": rkey,
···116116117117// DeleteRecord deletes a record from the ATProto repository
118118func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error {
119119- payload := map[string]interface{}{
119119+ payload := map[string]any{
120120 "repo": c.did,
121121 "collection": collection,
122122 "rkey": rkey,
+1-1
pkg/atproto/lexicon.go
···133133134134// ToOCIManifest converts the manifest record back to OCI manifest JSON
135135func (m *ManifestRecord) ToOCIManifest() ([]byte, error) {
136136- ociManifest := map[string]interface{}{
136136+ ociManifest := map[string]any{
137137 "schemaVersion": m.SchemaVersion,
138138 "mediaType": m.MediaType,
139139 "config": m.Config,