···228228 - `GetDefaultScopes()` - returns ATCR registry scopes
229229 - All OAuth flows (authorization, token exchange, refresh) in one place
230230231231-2. **DPoP Transport** (`transport.go`) - HTTP RoundTripper that auto-adds DPoP headers
232232-233233-3. **Token Storage** (`tokenstorage.go`) - Persists refresh tokens and DPoP keys for AppView
231231+2. **Token Storage** (`store.go`) - Persists OAuth sessions for AppView
234232 - File-based storage in `/var/lib/atcr/refresh-tokens.json` (AppView)
235233 - Client uses `~/.atcr/oauth-token.json` (credential helper)
236234237237-4. **Refresher** (`refresher.go`) - Token refresh manager for AppView
238238- - Caches access tokens with automatic refresh
235235+3. **Refresher** (`refresher.go`) - Token refresh manager for AppView
236236+ - Caches OAuth sessions with automatic token refresh (handled by indigo library)
239237 - Per-DID locking prevents concurrent refresh races
240238 - Uses Client methods for consistency
241239242242-5. **Server** (`server.go`) - OAuth authorization endpoints for AppView
240240+4. **Server** (`server.go`) - OAuth authorization endpoints for AppView
243241 - `GET /auth/oauth/authorize` - starts OAuth flow
244242 - `GET /auth/oauth/callback` - handles OAuth callback
245243 - Uses Client methods for authorization and token exchange
246244247247-6. **Interactive Flow** (`flow.go`) - Reusable OAuth flow for CLI tools
245245+5. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools
248246 - Used by credential helper and hold service registration
249247 - Two-phase callback setup ensures PAR metadata availability
250248···259257 - PAR request with DPoP header → get request_uri
260258 - User authorizes in browser
261259 - AppView exchanges code for OAuth token with DPoP proof
262262- - AppView stores: OAuth token, refresh token, DPoP key, DID, handle
260260+ - AppView stores: OAuth session (tokens managed by indigo library with DPoP), DID, handle
2632615. AppView shows device approval page: "Can [device] push to your account?"
2642626. User approves device
2652637. AppView issues registry JWT with validated DID
···27227012. Helper returns cached registry JWT (or re-authenticates if expired)
273271```
274272275275-**Key distinction:** The credential helper never manages OAuth tokens or DPoP keys directly. AppView owns the OAuth session and issues registry JWTs to the credential helper. This means AppView has access to user OAuth tokens and DPoP keys, which it needs for:
276276-- Writing manifests to user's PDS
277277-- Validating user sessions
278278-- Delegating access to hold services
273273+**Key distinction:** The credential helper never manages OAuth tokens directly. AppView owns the OAuth session (including DPoP handling via indigo library) and issues registry JWTs to the credential helper. AppView needs the OAuth session for:
274274+- Writing manifests to user's PDS (with DPoP authentication)
275275+- Getting service tokens from user's PDS (with DPoP authentication)
276276+- Service tokens are then used to authenticate to hold services (Bearer tokens, not DPoP)
279277280278**Security:**
281279- Tokens validated against authoritative source (user's PDS)
+2-2
README.md
···3131 - Users can deploy their own storage and control access via crew membership
323233333. **Credential Helper** - Client authentication
3434- - ATProto OAuth with DPoP
3434+ - ATProto OAuth (DPoP handled transparently)
3535 - Automatic authentication on first push/pull
36363737**Storage model:**
···43434444- ✅ **OCI-compliant** - Works with Docker, containerd, podman
4545- ✅ **Decentralized** - You own your manifest data via your PDS
4646-- ✅ **ATProto OAuth** - Secure authentication with DPoP
4646+- ✅ **ATProto OAuth** - Secure authentication (DPoP-compliant)
4747- ✅ **BYOS** - Deploy your own storage service
4848- ✅ **Web UI** - Browse, search, star repositories
4949- ✅ **Multi-backend** - S3, Storj, Minio, Azure, GCS, filesystem
+2-2
docs/APPVIEW-UI-V1.md
···1616- **Frontend:** TBD (Go templates/Templ or separate SPA)
1717- **Database:** SQLite (firehose data cache)
1818- **Styling:** TBD (plain CSS, Tailwind, etc.)
1919-- **Authentication:** OAuth with DPoP (reuse existing implementation)
1919+- **Authentication:** ATProto OAuth (DPoP handled by indigo library)
20202121### Components
2222···5015012. Redirects to `/auth/oauth/login?return_to=/ui/images`
5025023. User enters handle (e.g., "alice.bsky.social")
5035034. Server resolves handle → DID → PDS → OAuth server
504504-5. Server initiates OAuth flow with PAR + DPoP
504504+5. Server initiates ATProto OAuth flow with PAR (DPoP handled by indigo library)
5055056. User redirected to PDS for authorization
5065067. OAuth callback to `/auth/oauth/callback`
5075078. Server exchanges code for token, validates with PDS
+7-5
docs/EMBEDDED_PDS.md
···250250251251### Potential Solutions
252252253253-#### Option A: Direct User-to-Hold Authentication
253253+#### Option A: Direct User-to-Hold Authentication (NOT IMPLEMENTED)
254254255255-Users authenticate directly to holds (bypassing AppView service tokens).
255255+**Note:** This option was considered but NOT implemented. ATCR uses service tokens exclusively for AppView→Hold authentication.
256256+257257+Users would authenticate directly to holds (bypassing AppView service tokens).
256258257259**Pros:**
258260- ✅ Clear trust model (user ↔ hold)
···3153172. Clear security model for hold operators
316318317319**Long-term:**
318318-1. Explore direct user-to-hold OAuth
319319-2. Credential helper manages multiple hold sessions
320320-3. Auto-discover and authenticate to new holds
320320+1. Continue using service tokens (current implementation)
321321+2. Explore optimizations for service token caching
322322+3. Document security model more clearly
321323322324### Understanding getServiceAuth
323325
+5
pkg/hold/pds/auth.go
···1010 "slices"
1111 "strings"
1212 "time"
1313+ "log"
13141415 "atcr.io/pkg/atproto"
1516 "github.com/bluesky-social/indigo/atproto/atcrypto"
···425426 return nil, fmt.Errorf("missing token")
426427 }
427428429429+ log.Printf("[ValidateServiceToken] Validating service token for hold %s", holdDID)
430430+428431 // Manually parse JWT (bypass golang-jwt since it doesn't support ES256K algorithm used by ATProto)
429432 // Split token: header.payload.signature
430433 tokenParts := strings.Split(tokenString, ".")
···489492 if err := publicKey.HashAndVerify(signedData, signature); err != nil {
490493 return nil, fmt.Errorf("signature verification failed: %w", err)
491494 }
495495+496496+ log.Printf("[ValidateServiceToken] Successfully validated service token for user %s", issuerDID)
492497493498 // Return validated user
494499 return &ValidatedUser{
+15
pkg/hold/pds/xrpc.go
···11281128// This endpoint allows authenticated users to request crew membership
11291129// Authorization is checked against captain record settings
11301130func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request) {
11311131+ log.Printf("[HandleRequestCrew] Starting crew membership request")
11321132+11311133 // Get authenticated user from context (if coming through middleware)
11321134 // Otherwise validate directly (for tests or direct handler calls)
11331135 user := getUserFromContext(r)
···11351137 var err error
11361138 user, err = ValidateDPoPRequest(r, h.httpClient)
11371139 if err != nil {
11401140+ log.Printf("[HandleRequestCrew] Authentication failed: %v", err)
11381141 http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
11391142 return
11401143 }
11411144 }
11451145+ log.Printf("[HandleRequestCrew] Authenticated user: %s", user.DID)
1142114611431147 // Parse request body (optional parameters)
11441148 var req struct {
···11491153 // Body is optional - if empty, just use defaults
11501154 if r.Body != nil && r.ContentLength > 0 {
11511155 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
11561156+ log.Printf("[HandleRequestCrew] Failed to parse request body: %v", err)
11521157 http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
11531158 return
11541159 }
11551160 }
1156116111571162 // Get captain record to check authorization settings
11631163+ log.Printf("[HandleRequestCrew] Getting captain record...")
11581164 _, captain, err := h.pds.GetCaptainRecord(r.Context())
11591165 if err != nil {
11661166+ log.Printf("[HandleRequestCrew] Failed to get captain record: %v", err)
11601167 http.Error(w, fmt.Sprintf("failed to get captain record: %v", err), http.StatusInternalServerError)
11611168 return
11621169 }
11701170+ log.Printf("[HandleRequestCrew] Captain record retrieved: owner=%s, allowAllCrew=%v", captain.Owner, captain.AllowAllCrew)
1163117111641172 // Check authorization:
11651173 // 1. If allowAllCrew is true, any authenticated user can join
···1181118911821190 // Check if user is already a crew member
11831191 // List all crew members and check if this DID is already present
11921192+ log.Printf("[HandleRequestCrew] Checking existing crew membership...")
11841193 crew, err := h.pds.ListCrewMembers(r.Context())
11851194 if err != nil {
11951195+ log.Printf("[HandleRequestCrew] Failed to list crew members: %v", err)
11861196 http.Error(w, fmt.Sprintf("failed to list crew members: %v", err), http.StatusInternalServerError)
11871197 return
11881198 }
11991199+ log.Printf("[HandleRequestCrew] Found %d existing crew members", len(crew))
1189120011901201 for _, member := range crew {
11911202 if member.Record.Member == user.DID {
11921203 // Already a crew member, return success with existing record
12041204+ log.Printf("[HandleRequestCrew] User is already a crew member (rkey=%s)", member.Rkey)
11931205 response := map[string]any{
11941206 "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey),
11951207 "cid": member.Cid.String(),
···12041216 }
1205121712061218 // Create new crew record
12191219+ log.Printf("[HandleRequestCrew] Creating new crew record for user %s (role=%s, permissions=%v)", user.DID, req.Role, req.Permissions)
12071220 recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions)
12081221 if err != nil {
12221222+ log.Printf("[HandleRequestCrew] Failed to create crew record: %v", err)
12091223 http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError)
12101224 return
12111225 }
12261226+ log.Printf("[HandleRequestCrew] Successfully created crew record (CID=%s)", recordCID.String())
1212122712131228 // Return success response
12141229 // Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it