···475475476476Read access:
477477- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users
478478-- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read permission
478478+- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read OR blob:write permission
479479+- **Note:** `blob:write` implicitly grants `blob:read` access (can't push without pulling)
479480480481Write access:
481482- Hold owner OR crew members with blob:write permission
482483- Verified via `io.atcr.hold.crew` records in hold's embedded PDS
484484+485485+**Permission Matrix:**
486486+487487+| User Type | Public Read | Private Read | Write | Crew Admin |
488488+|-----------|-------------|--------------|-------|------------|
489489+| Anonymous | Yes | No | No | No |
490490+| Owner (captain) | Yes | Yes | Yes | Yes (implied) |
491491+| Crew (blob:read only) | Yes | Yes | No | No |
492492+| Crew (blob:write only) | Yes | Yes* | Yes | No |
493493+| Crew (blob:read + blob:write) | Yes | Yes | Yes | No |
494494+| Crew (crew:admin) | Yes | Yes | Yes | Yes |
495495+| Authenticated non-crew | Yes | No | No | No |
496496+497497+*`blob:write` implicitly grants `blob:read` access
498498+499499+**Authorization Error Format:**
500500+501501+All authorization failures use consistent structured errors (`pkg/hold/pds/auth.go`):
502502+```
503503+access denied for [action]: [reason] (required: [permission(s)])
504504+```
505505+506506+Examples:
507507+- `access denied for blob:read: user is not a crew member (required: blob:read or blob:write)`
508508+- `access denied for blob:write: crew member lacks permission (required: blob:write)`
509509+- `access denied for crew:admin: user is not a crew member (required: crew:admin)`
510510+511511+**Shared Error Constants** (`pkg/hold/pds/auth.go`):
512512+- `ErrMissingAuthHeader` - Missing Authorization header
513513+- `ErrInvalidAuthFormat` - Invalid Authorization header format
514514+- `ErrInvalidAuthScheme` - Invalid scheme (expected Bearer or DPoP)
515515+- `ErrInvalidJWTFormat` - Malformed JWT
516516+- `ErrMissingISSClaim` / `ErrMissingSubClaim` - Missing JWT claims
517517+- `ErrTokenExpired` - Token has expired
483518484519**Embedded PDS Endpoints** (`pkg/hold/pds/xrpc.go`):
485520
+399
docs/VALKEY_MIGRATION.md
···11+# Analysis: AppView SQL Database Usage
22+33+## Overview
44+55+The AppView uses SQLite with 19 tables. The key finding: **most data is a cache of ATProto records** that could theoretically be rebuilt from users' PDS instances.
66+77+## Data Categories
88+99+### 1. MUST PERSIST (Local State Only)
1010+1111+These tables contain data that **cannot be reconstructed** from external sources:
1212+1313+| Table | Purpose | Why It Must Persist |
1414+|-------|---------|---------------------|
1515+| `oauth_sessions` | OAuth tokens | Refresh tokens are stateful; losing them = users must re-auth |
1616+| `ui_sessions` | Web browser sessions | Session continuity for logged-in users |
1717+| `devices` | Approved devices + bcrypt secrets | User authorization decisions; secrets are one-way hashed |
1818+| `pending_device_auth` | In-flight auth flows | Short-lived (10min) but critical during auth |
1919+| `oauth_auth_requests` | OAuth flow state | Short-lived but required for auth completion |
2020+| `repository_stats` | Pull/push counts | **Locally tracked metrics** - not stored in ATProto |
2121+2222+### 2. CACHED FROM PDS (Rebuildable)
2323+2424+These tables are essentially a **read-through cache** of ATProto data:
2525+2626+| Table | Source | ATProto Collection |
2727+|-------|--------|-------------------|
2828+| `users` | User's PDS profile | `app.bsky.actor.profile` + DID document |
2929+| `manifests` | User's PDS | `io.atcr.manifest` records |
3030+| `tags` | User's PDS | `io.atcr.tag` records |
3131+| `layers` | Derived from manifests | Parsed from manifest content |
3232+| `manifest_references` | Derived from manifest lists | Parsed from multi-arch manifests |
3333+| `repository_annotations` | Manifest config blob | OCI annotations from config |
3434+| `repo_pages` | User's PDS | `io.atcr.repo.page` records |
3535+| `stars` | User's PDS | `io.atcr.sailor.star` records (synced via Jetstream) |
3636+| `hold_captain_records` | Hold's embedded PDS | `io.atcr.hold.captain` records |
3737+| `hold_crew_approvals` | Hold's embedded PDS | `io.atcr.hold.crew` records |
3838+| `hold_crew_denials` | Local authorization cache | Could re-check on demand |
3939+4040+### 3. OPERATIONAL
4141+4242+| Table | Purpose |
4343+|-------|---------|
4444+| `schema_migrations` | Migration tracking |
4545+| `firehose_cursor` | Jetstream position (can restart from 0) |
4646+4747+## Key Insights
4848+4949+### What's Actually Unique to AppView?
5050+5151+1. **Authentication state** - OAuth sessions, devices, UI sessions
5252+2. **Engagement metrics** - Pull/push counts (locally tracked, not in ATProto)
5353+5454+### What Could Be Eliminated?
5555+5656+If ATCR fully embraced the ATProto model:
5757+5858+1. **`users`** - Query PDS on demand (with caching)
5959+2. **`manifests`, `tags`, `layers`** - Query PDS on demand (with caching)
6060+3. **`repository_annotations`** - Fetch manifest config on demand
6161+4. **`repo_pages`** - Query PDS on demand
6262+5. **`hold_*` tables** - Query hold's PDS on demand
6363+6464+### Trade-offs
6565+6666+**Current approach (heavy caching):**
6767+- Fast queries for UI (search, browse, stats)
6868+- Offline resilience (PDS down doesn't break UI)
6969+- Complex sync logic (Jetstream consumer, backfill)
7070+- State can diverge from source of truth
7171+7272+**Lighter approach (query on demand):**
7373+- Always fresh data
7474+- Simpler codebase (no sync)
7575+- Slower queries (network round-trips)
7676+- Depends on PDS availability
7777+7878+## Current Limitation: No Cache-Miss Queries
7979+8080+**Finding:** There's no "query PDS on cache miss" logic. Users/manifests only enter the DB via:
8181+1. OAuth login (user authenticates)
8282+2. Jetstream events (firehose activity)
8383+8484+**Problem:** If someone visits `atcr.io/alice/myapp` before alice is indexed → 404
8585+8686+**Where this happens:**
8787+- `pkg/appview/handlers/repository.go:50-53`: If `db.GetUserByDID()` returns nil → 404
8888+- No fallback to `atproto.Client.ListRecords()` or similar
8989+9090+**This matters for Valkey migration:** If cache is ephemeral and restarts clear it, you need cache-miss logic to repopulate on demand. Otherwise:
9191+- Restart Valkey → all users/manifests gone
9292+- Wait for Jetstream to re-index OR implement cache-miss queries
9393+9494+**Cache-miss implementation design:**
9595+9696+Existing code to reuse: `pkg/appview/jetstream/processor.go:43-97` (`EnsureUser`)
9797+9898+```go
9999+// New: pkg/appview/cache/loader.go
100100+101101+type Loader struct {
102102+ cache Cache // Valkey interface
103103+ client *atproto.Client
104104+}
105105+106106+// GetUser with cache-miss fallback
107107+func (l *Loader) GetUser(ctx context.Context, did string) (*User, error) {
108108+ // 1. Try cache
109109+ if user := l.cache.GetUser(did); user != nil {
110110+ return user, nil
111111+ }
112112+113113+ // 2. Cache miss - resolve identity (already queries network)
114114+ _, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did)
115115+ if err != nil {
116116+ return nil, err // User doesn't exist in network
117117+ }
118118+119119+ // 3. Fetch profile for avatar
120120+ client := atproto.NewClient(pdsEndpoint, "", "")
121121+ profile, _ := client.GetProfileRecord(ctx, did)
122122+ avatarURL := ""
123123+ if profile != nil && profile.Avatar != nil {
124124+ avatarURL = atproto.BlobCDNURL(did, profile.Avatar.Ref.Link)
125125+ }
126126+127127+ // 4. Cache and return
128128+ user := &User{DID: did, Handle: handle, PDSEndpoint: pdsEndpoint, Avatar: avatarURL}
129129+ l.cache.SetUser(user, 1*time.Hour)
130130+ return user, nil
131131+}
132132+133133+// GetManifestsForRepo with cache-miss fallback
134134+func (l *Loader) GetManifestsForRepo(ctx context.Context, did, repo string) ([]Manifest, error) {
135135+ cacheKey := fmt.Sprintf("manifests:%s:%s", did, repo)
136136+137137+ // 1. Try cache
138138+ if cached := l.cache.Get(cacheKey); cached != nil {
139139+ return cached.([]Manifest), nil
140140+ }
141141+142142+ // 2. Cache miss - get user's PDS endpoint
143143+ user, err := l.GetUser(ctx, did)
144144+ if err != nil {
145145+ return nil, err
146146+ }
147147+148148+ // 3. Query PDS for manifests
149149+ client := atproto.NewClient(user.PDSEndpoint, "", "")
150150+ records, _, err := client.ListRecordsForRepo(ctx, did, atproto.ManifestCollection, 100, "")
151151+ if err != nil {
152152+ return nil, err
153153+ }
154154+155155+ // 4. Filter by repository and parse
156156+ var manifests []Manifest
157157+ for _, rec := range records {
158158+ var m atproto.ManifestRecord
159159+ if err := json.Unmarshal(rec.Value, &m); err != nil {
160160+ continue
161161+ }
162162+ if m.Repository == repo {
163163+ manifests = append(manifests, convertManifest(m))
164164+ }
165165+ }
166166+167167+ // 5. Cache and return
168168+ l.cache.Set(cacheKey, manifests, 10*time.Minute)
169169+ return manifests, nil
170170+}
171171+```
172172+173173+**Handler changes:**
174174+```go
175175+// Before (repository.go:45-53):
176176+owner, err := db.GetUserByDID(h.DB, did)
177177+if owner == nil {
178178+ RenderNotFound(w, r, h.Templates, h.RegistryURL)
179179+ return
180180+}
181181+182182+// After:
183183+owner, err := h.Loader.GetUser(r.Context(), did)
184184+if err != nil {
185185+ RenderNotFound(w, r, h.Templates, h.RegistryURL)
186186+ return
187187+}
188188+```
189189+190190+**Performance considerations:**
191191+- Cache hit: ~1ms (Valkey lookup)
192192+- Cache miss: ~200-500ms (PDS round-trip)
193193+- First request after restart: slower but correct
194194+- Jetstream still useful for proactive warming
195195+196196+---
197197+198198+## Proposed Architecture: Valkey + ATProto
199199+200200+### Goal
201201+Replace SQLite with Valkey (Redis-compatible) for ephemeral state, push remaining persistent data to ATProto.
202202+203203+### What goes to Valkey (ephemeral, TTL-based)
204204+205205+| Current Table | Valkey Key Pattern | TTL | Notes |
206206+|---------------|-------------------|-----|-------|
207207+| `oauth_sessions` | `oauth:{did}:{session_id}` | 90 days | Lost on restart = re-auth |
208208+| `ui_sessions` | `ui:{session_id}` | Session duration | Lost on restart = re-login |
209209+| `oauth_auth_requests` | `authreq:{state}` | 10 min | In-flight flows |
210210+| `pending_device_auth` | `pending:{device_code}` | 10 min | In-flight flows |
211211+| `firehose_cursor` | `cursor:jetstream` | None | Can restart from 0 |
212212+| All PDS cache tables | `cache:{collection}:{did}:{rkey}` | 10-60 min | Query PDS on miss |
213213+214214+**Benefits:**
215215+- Multi-instance ready (shared Valkey)
216216+- No schema migrations
217217+- Natural TTL expiry
218218+- Simpler code (no SQL)
219219+220220+### What could become ATProto records
221221+222222+| Current Table | Proposed Collection | Where Stored | Open Questions |
223223+|---------------|---------------------|--------------|----------------|
224224+| `devices` | `io.atcr.sailor.device` | User's PDS | Privacy: IP, user-agent sensitive? |
225225+| `repository_stats` | `io.atcr.repo.stats` | Hold's PDS or User's PDS | Who owns the stats? |
226226+227227+**Devices → Valkey:**
228228+- Move current device table to Valkey
229229+- Key: `device:{did}:{device_id}` → `{name, secret_hash, ip, user_agent, created_at, last_used}`
230230+- TTL: Long (1 year?) or no expiry
231231+- Device list: `devices:{did}` → Set of device IDs
232232+- Secret validation works the same, just different backend
233233+234234+**Service auth exploration (future):**
235235+The challenge with pure ATProto service auth is the AppView still needs the user's OAuth session to write manifests to their PDS. The current flow:
236236+1. User authenticates via OAuth → AppView gets OAuth tokens
237237+2. AppView issues registry JWT to credential helper
238238+3. Credential helper presents JWT on each push/pull
239239+4. AppView uses OAuth session to write to user's PDS
240240+241241+Service auth could work for the hold side (AppView → Hold), but not for the user's OAuth session.
242242+243243+**Repository stats → Hold's PDS:**
244244+245245+**Challenge discovered:** The hold's `getBlob` endpoint only receives `did` + `cid`, not the repository name.
246246+247247+Current flow (`proxy_blob_store.go:358-362`):
248248+```go
249249+xrpcURL := fmt.Sprintf("%s%s?did=%s&cid=%s&method=%s",
250250+ p.holdURL, atproto.SyncGetBlob, p.ctx.DID, dgst.String(), operation)
251251+```
252252+253253+**Implementation options:**
254254+255255+**Option A: Add repository parameter to getBlob (recommended)**
256256+```go
257257+// Modified AppView call:
258258+xrpcURL := fmt.Sprintf("%s%s?did=%s&cid=%s&method=%s&repo=%s",
259259+ p.holdURL, atproto.SyncGetBlob, p.ctx.DID, dgst.String(), operation, p.ctx.Repository)
260260+```
261261+262262+```go
263263+// Modified hold handler (xrpc.go:969):
264264+func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
265265+ did := r.URL.Query().Get("did")
266266+ cidOrDigest := r.URL.Query().Get("cid")
267267+ repo := r.URL.Query().Get("repo") // NEW
268268+269269+ // ... existing blob handling ...
270270+271271+ // Increment stats if repo provided
272272+ if repo != "" {
273273+ go h.pds.IncrementPullCount(did, repo) // Async, non-blocking
274274+ }
275275+}
276276+```
277277+278278+**Stats record structure:**
279279+```
280280+Collection: io.atcr.hold.stats
281281+Rkey: base64(did:repository) // Deterministic, unique
282282+283283+{
284284+ "$type": "io.atcr.hold.stats",
285285+ "did": "did:plc:alice123",
286286+ "repository": "myapp",
287287+ "pullCount": 1542,
288288+ "pushCount": 47,
289289+ "lastPull": "2025-01-15T...",
290290+ "lastPush": "2025-01-10T...",
291291+ "createdAt": "2025-01-01T..."
292292+}
293293+```
294294+295295+**Hold-side implementation:**
296296+```go
297297+// New file: pkg/hold/pds/stats.go
298298+299299+func (p *HoldPDS) IncrementPullCount(ctx context.Context, did, repo string) error {
300300+ rkey := statsRecordKey(did, repo)
301301+302302+ // Get or create stats record
303303+ stats, err := p.GetStatsRecord(ctx, rkey)
304304+ if err != nil || stats == nil {
305305+ stats = &atproto.StatsRecord{
306306+ Type: atproto.StatsCollection,
307307+ DID: did,
308308+ Repository: repo,
309309+ PullCount: 0,
310310+ PushCount: 0,
311311+ CreatedAt: time.Now(),
312312+ }
313313+ }
314314+315315+ // Increment and update
316316+ stats.PullCount++
317317+ stats.LastPull = time.Now()
318318+319319+ _, err = p.repomgr.UpdateRecord(ctx, p.uid, atproto.StatsCollection, rkey, stats)
320320+ return err
321321+}
322322+```
323323+324324+**Query endpoint (new XRPC):**
325325+```
326326+GET /xrpc/io.atcr.hold.getStats?did={userDID}&repo={repository}
327327+→ Returns JSON: { pullCount, pushCount, lastPull, lastPush }
328328+329329+GET /xrpc/io.atcr.hold.listStats?did={userDID}
330330+→ Returns all stats for a user across all repos on this hold
331331+```
332332+333333+**AppView aggregation:**
334334+```go
335335+func (l *Loader) GetAggregatedStats(ctx context.Context, did, repo string) (*Stats, error) {
336336+ // 1. Get all holds that have served this repo
337337+ holdDIDs, _ := l.cache.GetHoldDIDsForRepo(did, repo)
338338+339339+ // 2. Query each hold for stats
340340+ var total Stats
341341+ for _, holdDID := range holdDIDs {
342342+ holdURL := resolveHoldDID(holdDID)
343343+ stats, _ := queryHoldStats(ctx, holdURL, did, repo)
344344+ total.PullCount += stats.PullCount
345345+ total.PushCount += stats.PushCount
346346+ }
347347+348348+ return &total, nil
349349+}
350350+```
351351+352352+**Files to modify:**
353353+- `pkg/atproto/lexicon.go` - Add `StatsCollection` + `StatsRecord`
354354+- `pkg/hold/pds/stats.go` - New file for stats operations
355355+- `pkg/hold/pds/xrpc.go` - Add `repo` param to getBlob, add stats endpoints
356356+- `pkg/appview/storage/proxy_blob_store.go` - Pass repository to getBlob
357357+- `pkg/appview/cache/loader.go` - Aggregation logic
358358+359359+### Migration Path
360360+361361+**Phase 1: Add Valkey infrastructure**
362362+- Add Valkey client to AppView
363363+- Create store interfaces that abstract SQLite vs Valkey
364364+- Dual-write OAuth sessions to both
365365+366366+**Phase 2: Migrate sessions to Valkey**
367367+- OAuth sessions, UI sessions, auth requests, pending device auth
368368+- Remove SQLite session tables
369369+- Test: restart AppView, users get logged out (acceptable)
370370+371371+**Phase 3: Migrate devices to Valkey**
372372+- Move device store to Valkey
373373+- Same data structure, different backend
374374+- Consider device expiry policy
375375+376376+**Phase 4: Implement hold-side stats**
377377+- Add `io.atcr.hold.stats` collection to hold's embedded PDS
378378+- Hold increments stats on blob access
379379+- Add XRPC endpoint: `io.atcr.hold.getStats`
380380+381381+**Phase 5: AppView stats aggregation**
382382+- Track holdDids per repo in Valkey cache
383383+- Query holds for stats, aggregate
384384+- Cache aggregated stats with TTL
385385+386386+**Phase 6: Remove SQLite (optional)**
387387+- Keep SQLite as optional cache layer for UI queries
388388+- Or: Query PDS on demand with Valkey caching
389389+- Jetstream still useful for real-time updates
390390+391391+## Summary Table
392392+393393+| Category | Tables | % of Schema | Truly Persistent? |
394394+|----------|--------|-------------|-------------------|
395395+| Auth & Sessions + Metrics | 6 | 32% | Yes |
396396+| PDS Cache | 11 | 58% | No (rebuildable) |
397397+| Operational | 2 | 10% | No |
398398+399399+**~58% of the database is cached ATProto data that could be rebuilt from PDSes.**
+127-104
pkg/appview/middleware/registry.go
···2929// authMethodKey is the context key for storing auth method from JWT
3030const authMethodKey contextKey = "auth.method"
31313232+// pullerDIDKey is the context key for storing the authenticated user's DID from JWT
3333+const pullerDIDKey contextKey = "puller.did"
3434+3235// validationCacheEntry stores a validated service token with expiration
3336type validationCacheEntry struct {
3437 serviceToken string
···302305 // Get service token for hold authentication (only if authenticated)
303306 // Use validation cache to prevent concurrent requests from racing on OAuth/DPoP
304307 // Route based on auth method from JWT token
308308+ // IMPORTANT: Use PULLER's DID/PDS for service token, not owner's!
309309+ // The puller (authenticated user) needs to authenticate to the hold service.
305310 var serviceToken string
306311 authMethod, _ := ctx.Value(authMethodKey).(string)
312312+ pullerDID, _ := ctx.Value(pullerDIDKey).(string)
313313+ var pullerPDSEndpoint string
307314308315 // Only fetch service token if user is authenticated
309316 // Unauthenticated requests (like /v2/ ping) should not trigger token fetching
310310- if authMethod != "" {
311311- // Create cache key: "did:holdDID"
312312- cacheKey := fmt.Sprintf("%s:%s", did, holdDID)
317317+ if authMethod != "" && pullerDID != "" {
318318+ // Resolve puller's PDS endpoint for service token request
319319+ _, _, pullerPDSEndpoint, err = atproto.ResolveIdentity(ctx, pullerDID)
320320+ if err != nil {
321321+ slog.Warn("Failed to resolve puller's PDS, falling back to anonymous access",
322322+ "component", "registry/middleware",
323323+ "pullerDID", pullerDID,
324324+ "error", err)
325325+ // Continue without service token - hold will decide if anonymous access is allowed
326326+ } else {
327327+ // Create cache key: "pullerDID:holdDID"
328328+ cacheKey := fmt.Sprintf("%s:%s", pullerDID, holdDID)
313329314314- // Fetch service token through validation cache
315315- // This ensures only ONE request per DID:holdDID pair fetches the token
316316- // Concurrent requests will wait for the first request to complete
317317- var fetchErr error
318318- serviceToken, fetchErr = nr.validationCache.getOrFetch(ctx, cacheKey, func() (string, error) {
319319- if authMethod == token.AuthMethodAppPassword {
320320- // App-password flow: use Bearer token authentication
321321- slog.Debug("Using app-password flow for service token",
322322- "component", "registry/middleware",
323323- "did", did,
324324- "cacheKey", cacheKey)
330330+ // Fetch service token through validation cache
331331+ // This ensures only ONE request per pullerDID:holdDID pair fetches the token
332332+ // Concurrent requests will wait for the first request to complete
333333+ var fetchErr error
334334+ serviceToken, fetchErr = nr.validationCache.getOrFetch(ctx, cacheKey, func() (string, error) {
335335+ if authMethod == token.AuthMethodAppPassword {
336336+ // App-password flow: use Bearer token authentication
337337+ slog.Debug("Using app-password flow for service token",
338338+ "component", "registry/middleware",
339339+ "pullerDID", pullerDID,
340340+ "cacheKey", cacheKey)
325341326326- token, err := token.GetOrFetchServiceTokenWithAppPassword(ctx, did, holdDID, pdsEndpoint)
327327- if err != nil {
328328- slog.Error("Failed to get service token with app-password",
342342+ token, err := token.GetOrFetchServiceTokenWithAppPassword(ctx, pullerDID, holdDID, pullerPDSEndpoint)
343343+ if err != nil {
344344+ slog.Error("Failed to get service token with app-password",
345345+ "component", "registry/middleware",
346346+ "pullerDID", pullerDID,
347347+ "holdDID", holdDID,
348348+ "pullerPDSEndpoint", pullerPDSEndpoint,
349349+ "error", err)
350350+ return "", err
351351+ }
352352+ return token, nil
353353+ } else if nr.refresher != nil {
354354+ // OAuth flow: use DPoP authentication
355355+ slog.Debug("Using OAuth flow for service token",
329356 "component", "registry/middleware",
330330- "did", did,
331331- "holdDID", holdDID,
332332- "pdsEndpoint", pdsEndpoint,
333333- "error", err)
334334- return "", err
357357+ "pullerDID", pullerDID,
358358+ "cacheKey", cacheKey)
359359+360360+ token, err := token.GetOrFetchServiceToken(ctx, nr.refresher, pullerDID, holdDID, pullerPDSEndpoint)
361361+ if err != nil {
362362+ slog.Error("Failed to get service token with OAuth",
363363+ "component", "registry/middleware",
364364+ "pullerDID", pullerDID,
365365+ "holdDID", holdDID,
366366+ "pullerPDSEndpoint", pullerPDSEndpoint,
367367+ "error", err)
368368+ return "", err
369369+ }
370370+ return token, nil
335371 }
336336- return token, nil
337337- } else if nr.refresher != nil {
338338- // OAuth flow: use DPoP authentication
339339- slog.Debug("Using OAuth flow for service token",
340340- "component", "registry/middleware",
341341- "did", did,
342342- "cacheKey", cacheKey)
372372+ return "", fmt.Errorf("no authentication method available")
373373+ })
343374344344- token, err := token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
345345- if err != nil {
346346- slog.Error("Failed to get service token with OAuth",
347347- "component", "registry/middleware",
348348- "did", did,
349349- "holdDID", holdDID,
350350- "pdsEndpoint", pdsEndpoint,
351351- "error", err)
352352- return "", err
375375+ // Handle errors from cached fetch
376376+ if fetchErr != nil {
377377+ errMsg := fetchErr.Error()
378378+379379+ // Check for app-password specific errors
380380+ if authMethod == token.AuthMethodAppPassword {
381381+ if strings.Contains(errMsg, "expired or invalid") || strings.Contains(errMsg, "no app-password") {
382382+ return nil, nr.authErrorMessage("App-password authentication failed. Please re-authenticate with: docker login")
383383+ }
353384 }
354354- return token, nil
355355- }
356356- return "", fmt.Errorf("no authentication method available")
357357- })
358385359359- // Handle errors from cached fetch
360360- if fetchErr != nil {
361361- errMsg := fetchErr.Error()
362362-363363- // Check for app-password specific errors
364364- if authMethod == token.AuthMethodAppPassword {
365365- if strings.Contains(errMsg, "expired or invalid") || strings.Contains(errMsg, "no app-password") {
366366- return nil, nr.authErrorMessage("App-password authentication failed. Please re-authenticate with: docker login")
386386+ // Check for OAuth specific errors
387387+ if strings.Contains(errMsg, "OAuth session") || strings.Contains(errMsg, "OAuth validation") {
388388+ return nil, nr.authErrorMessage("OAuth session expired or invalidated by PDS. Your session has been cleared")
367389 }
368368- }
369390370370- // Check for OAuth specific errors
371371- if strings.Contains(errMsg, "OAuth session") || strings.Contains(errMsg, "OAuth validation") {
372372- return nil, nr.authErrorMessage("OAuth session expired or invalidated by PDS. Your session has been cleared")
391391+ // Generic service token error
392392+ return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", fetchErr))
373393 }
374374-375375- // Generic service token error
376376- return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", fetchErr))
377394 }
378395 } else {
379396 slog.Debug("Skipping service token fetch for unauthenticated request",
380397 "component", "registry/middleware",
381381- "did", did)
398398+ "ownerDID", did)
382399 }
383400384401 // Create a new reference with identity/image format
···396413 return nil, err
397414 }
398415399399- // Get access token for PDS operations
400400- // Use auth method from JWT to determine client type:
401401- // - OAuth users: use session provider (DPoP-enabled)
402402- // - App-password users: use Basic Auth token cache
416416+ // Create ATProto client for manifest/tag operations
417417+ // Pulls: ATProto records are public, no auth needed
418418+ // Pushes: Need auth, but puller must be owner anyway
403419 var atprotoClient *atproto.Client
404420405405- if authMethod == token.AuthMethodOAuth && nr.refresher != nil {
406406- // OAuth flow: use session provider for locked OAuth sessions
407407- // This prevents DPoP nonce race conditions during concurrent layer uploads
408408- slog.Debug("Creating ATProto client with OAuth session provider",
409409- "component", "registry/middleware",
410410- "did", did,
411411- "authMethod", authMethod)
412412- atprotoClient = atproto.NewClientWithSessionProvider(pdsEndpoint, did, nr.refresher)
413413- } else {
414414- // App-password flow (or fallback): use Basic Auth token cache
415415- accessToken, ok := auth.GetGlobalTokenCache().Get(did)
416416- if !ok {
417417- slog.Debug("No cached access token found for app-password auth",
418418- "component", "registry/middleware",
419419- "did", did,
420420- "authMethod", authMethod)
421421- accessToken = "" // Will fail on manifest push, but let it try
421421+ if pullerDID == did {
422422+ // Puller is owner - may need auth for pushes
423423+ if authMethod == token.AuthMethodOAuth && nr.refresher != nil {
424424+ atprotoClient = atproto.NewClientWithSessionProvider(pdsEndpoint, did, nr.refresher)
425425+ } else if authMethod == token.AuthMethodAppPassword {
426426+ accessToken, _ := auth.GetGlobalTokenCache().Get(did)
427427+ atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
422428 } else {
423423- slog.Debug("Creating ATProto client with app-password",
424424- "component", "registry/middleware",
425425- "did", did,
426426- "authMethod", authMethod,
427427- "token_length", len(accessToken))
429429+ atprotoClient = atproto.NewClient(pdsEndpoint, did, "")
428430 }
429429- atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
431431+ } else {
432432+ // Puller != owner - reads only, no auth needed
433433+ atprotoClient = atproto.NewClient(pdsEndpoint, did, "")
430434 }
431435432436 // IMPORTANT: Use only the image name (not identity/image) for ATProto storage
···449453 // 3. The refresher already caches sessions efficiently (in-memory + DB)
450454 // 4. Caching the repository with a stale ATProtoClient causes refresh token errors
451455 registryCtx := &storage.RegistryContext{
452452- DID: did,
453453- Handle: handle,
454454- HoldDID: holdDID,
455455- PDSEndpoint: pdsEndpoint,
456456- Repository: repositoryName,
457457- ServiceToken: serviceToken, // Cached service token from middleware validation
458458- ATProtoClient: atprotoClient,
459459- AuthMethod: authMethod, // Auth method from JWT token
460460- Database: nr.database,
461461- Authorizer: nr.authorizer,
462462- Refresher: nr.refresher,
463463- ReadmeFetcher: nr.readmeFetcher,
456456+ DID: did,
457457+ Handle: handle,
458458+ HoldDID: holdDID,
459459+ PDSEndpoint: pdsEndpoint,
460460+ Repository: repositoryName,
461461+ ServiceToken: serviceToken, // Cached service token from puller's PDS
462462+ ATProtoClient: atprotoClient,
463463+ AuthMethod: authMethod, // Auth method from JWT token
464464+ PullerDID: pullerDID, // Authenticated user making the request
465465+ PullerPDSEndpoint: pullerPDSEndpoint, // Puller's PDS for service token refresh
466466+ Database: nr.database,
467467+ Authorizer: nr.authorizer,
468468+ Refresher: nr.refresher,
469469+ ReadmeFetcher: nr.readmeFetcher,
464470 }
465471466472 return storage.NewRoutingRepository(repo, registryCtx), nil
···533539 return false
534540}
535541536536-// ExtractAuthMethod is an HTTP middleware that extracts the auth method from the JWT Authorization header
537537-// and stores it in the request context for later use by the registry middleware
542542+// ExtractAuthMethod is an HTTP middleware that extracts the auth method and puller DID from the JWT Authorization header
543543+// and stores them in the request context for later use by the registry middleware.
544544+// Also stores the HTTP method for routing decisions (GET/HEAD = pull, PUT/POST = push).
538545func ExtractAuthMethod(next http.Handler) http.Handler {
539546 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
547547+ ctx := r.Context()
548548+549549+ // Store HTTP method in context for routing decisions
550550+ // This is used by routing_repository.go to distinguish pull (GET/HEAD) from push (PUT/POST)
551551+ ctx = context.WithValue(ctx, "http.request.method", r.Method)
552552+540553 // Extract Authorization header
541554 authHeader := r.Header.Get("Authorization")
542555 if authHeader != "" {
···549562 authMethod := token.ExtractAuthMethod(tokenString)
550563 if authMethod != "" {
551564 // Store in context for registry middleware
552552- ctx := context.WithValue(r.Context(), authMethodKey, authMethod)
553553- r = r.WithContext(ctx)
554554- slog.Debug("Extracted auth method from JWT",
555555- "component", "registry/middleware",
556556- "authMethod", authMethod)
565565+ ctx = context.WithValue(ctx, authMethodKey, authMethod)
566566+ }
567567+568568+ // Extract puller DID (Subject) from JWT
569569+ // This is the authenticated user's DID, used for service token requests
570570+ pullerDID := token.ExtractSubject(tokenString)
571571+ if pullerDID != "" {
572572+ ctx = context.WithValue(ctx, pullerDIDKey, pullerDID)
557573 }
574574+575575+ slog.Debug("Extracted auth info from JWT",
576576+ "component", "registry/middleware",
577577+ "authMethod", authMethod,
578578+ "pullerDID", pullerDID,
579579+ "httpMethod", r.Method)
558580 }
559581 }
560582583583+ r = r.WithContext(ctx)
561584 next.ServeHTTP(w, r)
562585 })
563586}
+12-8
pkg/appview/storage/context.go
···1818// This includes both per-request data (DID, hold) and shared services
1919type RegistryContext struct {
2020 // Per-request identity and routing information
2121- DID string // User's DID (e.g., "did:plc:abc123")
2222- Handle string // User's handle (e.g., "alice.bsky.social")
2323- HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io")
2424- PDSEndpoint string // User's PDS endpoint URL
2525- Repository string // Image repository name (e.g., "debian")
2626- ServiceToken string // Service token for hold authentication (cached by middleware)
2727- ATProtoClient *atproto.Client // Authenticated ATProto client for this user
2828- AuthMethod string // Auth method used ("oauth" or "app_password")
2121+ // Owner = the user whose repository is being accessed
2222+ // Puller = the authenticated user making the request (from JWT Subject)
2323+ DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123")
2424+ Handle string // Owner's handle (e.g., "alice.bsky.social")
2525+ HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io")
2626+ PDSEndpoint string // Owner's PDS endpoint URL
2727+ Repository string // Image repository name (e.g., "debian")
2828+ ServiceToken string // Service token for hold authentication (from puller's PDS)
2929+ ATProtoClient *atproto.Client // Authenticated ATProto client for the owner
3030+ AuthMethod string // Auth method used ("oauth" or "app_password")
3131+ PullerDID string // Puller's DID - who is making the request (from JWT Subject)
3232+ PullerPDSEndpoint string // Puller's PDS endpoint URL
29333034 // Shared services (same for all requests)
3135 Database DatabaseMetrics // Metrics tracking database
+11-5
pkg/appview/storage/manifest_store.go
···88 "fmt"
99 "io"
1010 "log/slog"
1111- "maps"
1211 "net/http"
1312 "strings"
1413 "sync"
···180179 if err != nil {
181180 // Log error but don't fail the push - labels are optional
182181 slog.Warn("Failed to extract config labels", "error", err)
183183- } else {
182182+ } else if len(labels) > 0 {
184183 // Initialize annotations map if needed
185184 if manifestRecord.Annotations == nil {
186185 manifestRecord.Annotations = make(map[string]string)
187186 }
188187189189- // Copy labels to annotations (Dockerfile LABELs → manifest annotations)
190190- maps.Copy(manifestRecord.Annotations, labels)
188188+ // Copy labels to annotations as fallback
189189+ // Only set label values for keys NOT already in manifest annotations
190190+ // This ensures explicit annotations take precedence over Dockerfile LABELs
191191+ // (which may be inherited from base images)
192192+ for key, value := range labels {
193193+ if _, exists := manifestRecord.Annotations[key]; !exists {
194194+ manifestRecord.Annotations[key] = value
195195+ }
196196+ }
191197192192- slog.Debug("Extracted labels from config blob", "count", len(labels))
198198+ slog.Debug("Merged labels from config blob", "labelsCount", len(labels), "annotationsCount", len(manifestRecord.Annotations))
193199 }
194200 }
195201
+2-2
pkg/appview/storage/routing_repository.go
···6464 return blobStore
6565 }
66666767- // Determine if this is a pull (GET) or push (PUT/POST/HEAD/etc) operation
6767+ // Determine if this is a pull (GET/HEAD) or push (PUT/POST/etc) operation
6868 // Pull operations use the historical hold DID from the database (blobs are where they were pushed)
6969 // Push operations use the discovery-based hold DID from user's profile/default
7070 // This allows users to change their default hold and have new pushes go there
7171 isPull := false
7272 if method, ok := ctx.Value("http.request.method").(string); ok {
7373- isPull = method == "GET"
7373+ isPull = method == "GET" || method == "HEAD"
7474 }
75757676 holdDID := r.Ctx.HoldDID // Default to discovery-based DID
+19-17
pkg/appview/storage/routing_repository_test.go
···109109 assert.NotNil(t, repo.manifestStore)
110110}
111111112112-// TestRoutingRepository_Blobs_PullUsesDatabase tests that GET (pull) uses database hold DID
112112+// TestRoutingRepository_Blobs_PullUsesDatabase tests that GET and HEAD (pull) use database hold DID
113113func TestRoutingRepository_Blobs_PullUsesDatabase(t *testing.T) {
114114 dbHoldDID := "did:web:database.hold.io"
115115 discoveryHoldDID := "did:web:discovery.hold.io"
116116117117- ctx := &RegistryContext{
118118- DID: "did:plc:test123",
119119- Repository: "myapp",
120120- HoldDID: discoveryHoldDID, // Discovery-based hold (should be overridden for pull)
121121- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
122122- Database: &mockDatabase{holdDID: dbHoldDID},
123123- }
124124-125125- repo := NewRoutingRepository(nil, ctx)
117117+ // Test both GET and HEAD as pull operations
118118+ for _, method := range []string{"GET", "HEAD"} {
119119+ // Reset context for each test
120120+ ctx := &RegistryContext{
121121+ DID: "did:plc:test123",
122122+ Repository: "myapp-" + method, // Unique repo to avoid caching
123123+ HoldDID: discoveryHoldDID,
124124+ ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
125125+ Database: &mockDatabase{holdDID: dbHoldDID},
126126+ }
127127+ repo := NewRoutingRepository(nil, ctx)
126128127127- // Create context with GET method (pull operation)
128128- pullCtx := context.WithValue(context.Background(), "http.request.method", "GET")
129129- blobStore := repo.Blobs(pullCtx)
129129+ pullCtx := context.WithValue(context.Background(), "http.request.method", method)
130130+ blobStore := repo.Blobs(pullCtx)
130131131131- assert.NotNil(t, blobStore)
132132- // Verify the hold DID was updated to use the database value for pull
133133- assert.Equal(t, dbHoldDID, repo.Ctx.HoldDID, "pull (GET) should use database hold DID")
132132+ assert.NotNil(t, blobStore)
133133+ // Verify the hold DID was updated to use the database value for pull
134134+ assert.Equal(t, dbHoldDID, repo.Ctx.HoldDID, "pull (%s) should use database hold DID", method)
135135+ }
134136}
135137136138// TestRoutingRepository_Blobs_PushUsesDiscovery tests that push operations use discovery hold DID
···144146 }{
145147 {"PUT", "PUT"},
146148 {"POST", "POST"},
147147- {"HEAD", "HEAD"},
149149+ // HEAD is now treated as pull (like GET) - see TestRoutingRepository_Blobs_Pull
148150 {"PATCH", "PATCH"},
149151 {"DELETE", "DELETE"},
150152 }
+19
pkg/auth/token/claims.go
···56565757 return claims.AuthMethod
5858}
5959+6060+// ExtractSubject parses a JWT token string and extracts the Subject claim (the user's DID)
6161+// Returns the subject or empty string if not found or token is invalid
6262+// This does NOT validate the token - it only parses it to extract the claim
6363+func ExtractSubject(tokenString string) string {
6464+ // Parse token without validation (we only need the claims, validation is done by distribution library)
6565+ parser := jwt.NewParser(jwt.WithoutClaimsValidation())
6666+ token, _, err := parser.ParseUnverified(tokenString, &Claims{})
6767+ if err != nil {
6868+ return "" // Invalid token format
6969+ }
7070+7171+ claims, ok := token.Claims.(*Claims)
7272+ if !ok {
7373+ return "" // Wrong claims type
7474+ }
7575+7676+ return claims.Subject
7777+}
+70-27
pkg/hold/pds/auth.go
···44 "context"
55 "encoding/base64"
66 "encoding/json"
77+ "errors"
78 "fmt"
89 "io"
910 "log/slog"
···1819 "github.com/golang-jwt/jwt/v5"
1920)
20212222+// Authentication errors
2323+var (
2424+ ErrMissingAuthHeader = errors.New("missing Authorization header")
2525+ ErrInvalidAuthFormat = errors.New("invalid Authorization header format")
2626+ ErrInvalidAuthScheme = errors.New("invalid authorization scheme: expected 'Bearer' or 'DPoP'")
2727+ ErrMissingToken = errors.New("missing token")
2828+ ErrMissingDPoPHeader = errors.New("missing DPoP header")
2929+)
3030+3131+// JWT validation errors
3232+var (
3333+ ErrInvalidJWTFormat = errors.New("invalid JWT format: expected header.payload.signature")
3434+ ErrMissingISSClaim = errors.New("missing 'iss' claim in token")
3535+ ErrMissingSubClaim = errors.New("missing 'sub' claim in token")
3636+ ErrTokenExpired = errors.New("token has expired")
3737+)
3838+3939+// AuthError provides structured authorization error information
4040+type AuthError struct {
4141+ Action string // The action being attempted: "blob:read", "blob:write", "crew:admin"
4242+ Reason string // Why access was denied
4343+ Required []string // What permission(s) would grant access
4444+}
4545+4646+func (e *AuthError) Error() string {
4747+ return fmt.Sprintf("access denied for %s: %s (required: %s)",
4848+ e.Action, e.Reason, strings.Join(e.Required, " or "))
4949+}
5050+5151+// NewAuthError creates a new AuthError
5252+func NewAuthError(action, reason string, required ...string) *AuthError {
5353+ return &AuthError{
5454+ Action: action,
5555+ Reason: reason,
5656+ Required: required,
5757+ }
5858+}
5959+2160// HTTPClient interface allows injecting a custom HTTP client for testing
2261type HTTPClient interface {
2362 Do(*http.Request) (*http.Response, error)
···4483 // Extract Authorization header
4584 authHeader := r.Header.Get("Authorization")
4685 if authHeader == "" {
4747- return nil, fmt.Errorf("missing Authorization header")
8686+ return nil, ErrMissingAuthHeader
4887 }
49885089 // Check for DPoP authorization scheme
5190 parts := strings.SplitN(authHeader, " ", 2)
5291 if len(parts) != 2 {
5353- return nil, fmt.Errorf("invalid Authorization header format")
9292+ return nil, ErrInvalidAuthFormat
5493 }
55945695 if parts[0] != "DPoP" {
···59986099 accessToken := parts[1]
61100 if accessToken == "" {
6262- return nil, fmt.Errorf("missing access token")
101101+ return nil, ErrMissingToken
63102 }
6410365104 // Extract DPoP header
66105 dpopProof := r.Header.Get("DPoP")
67106 if dpopProof == "" {
6868- return nil, fmt.Errorf("missing DPoP header")
107107+ return nil, ErrMissingDPoPHeader
69108 }
7010971110 // TODO: We could verify the DPoP proof locally (signature, HTM, HTU, etc.)
···109148 // JWT format: header.payload.signature
110149 parts := strings.Split(token, ".")
111150 if len(parts) != 3 {
112112- return "", "", fmt.Errorf("invalid JWT format")
151151+ return "", "", ErrInvalidJWTFormat
113152 }
114153115154 // Decode payload (base64url)
···129168 }
130169131170 if claims.Sub == "" {
132132- return "", "", fmt.Errorf("missing sub claim (DID)")
171171+ return "", "", ErrMissingSubClaim
133172 }
134173135174 if claims.Iss == "" {
136136- return "", "", fmt.Errorf("missing iss claim (PDS)")
175175+ return "", "", ErrMissingISSClaim
137176 }
138177139178 return claims.Sub, claims.Iss, nil
···216255 return nil, fmt.Errorf("DPoP authentication failed: %w", err)
217256 }
218257 } else {
219219- return nil, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)")
258258+ return nil, ErrInvalidAuthScheme
220259 }
221260222261 // Get captain record to check owner
···243282 return user, nil
244283 }
245284 // User is crew but doesn't have admin permission
246246- return nil, fmt.Errorf("crew member lacks required 'crew:admin' permission")
285285+ return nil, NewAuthError("crew:admin", "crew member lacks permission", "crew:admin")
247286 }
248287 }
249288250289 // User is neither owner nor authorized crew
251251- return nil, fmt.Errorf("user is not authorized (must be hold owner or crew admin)")
290290+ return nil, NewAuthError("crew:admin", "user is not a crew member", "crew:admin")
252291}
253292254293// ValidateBlobWriteAccess validates that the request has valid authentication
···276315 return nil, fmt.Errorf("DPoP authentication failed: %w", err)
277316 }
278317 } else {
279279- return nil, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)")
318318+ return nil, ErrInvalidAuthScheme
280319 }
281320282321 // Get captain record to check owner and public settings
···303342 return user, nil
304343 }
305344 // User is crew but doesn't have write permission
306306- return nil, fmt.Errorf("crew member lacks required 'blob:write' permission")
345345+ return nil, NewAuthError("blob:write", "crew member lacks permission", "blob:write")
307346 }
308347 }
309348310349 // User is neither owner nor authorized crew
311311- return nil, fmt.Errorf("user is not authorized for blob write (must be hold owner or crew with blob:write permission)")
350350+ return nil, NewAuthError("blob:write", "user is not a crew member", "blob:write")
312351}
313352314353// ValidateBlobReadAccess validates that the request has read access to blobs
315354// If captain.public = true: No auth required (returns nil user to indicate public access)
316316-// If captain.public = false: Requires valid DPoP + OAuth and (captain OR crew with blob:read permission).
355355+// If captain.public = false: Requires valid DPoP + OAuth and (captain OR crew with blob:read or blob:write permission).
356356+// Note: blob:write implicitly grants blob:read access.
317357// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
318358func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
319359 // Get captain record to check public setting
···344384 return nil, fmt.Errorf("DPoP authentication failed: %w", err)
345385 }
346386 } else {
347347- return nil, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)")
387387+ return nil, ErrInvalidAuthScheme
348388 }
349389350390 // Check if user is the owner (always has read access)
···352392 return user, nil
353393 }
354394355355- // Check if user is crew with blob:read permission
395395+ // Check if user is crew with blob:read or blob:write permission
396396+ // Note: blob:write implicitly grants blob:read access
356397 crew, err := pds.ListCrewMembers(r.Context())
357398 if err != nil {
358399 return nil, fmt.Errorf("failed to check crew membership: %w", err)
···360401361402 for _, member := range crew {
362403 if member.Record.Member == user.DID {
363363- // Check if this crew member has blob:read permission
364364- if slices.Contains(member.Record.Permissions, "blob:read") {
404404+ // Check if this crew member has blob:read or blob:write permission
405405+ // blob:write implicitly grants read access (can't push without pulling)
406406+ if slices.Contains(member.Record.Permissions, "blob:read") ||
407407+ slices.Contains(member.Record.Permissions, "blob:write") {
365408 return user, nil
366409 }
367367- // User is crew but doesn't have read permission
368368- return nil, fmt.Errorf("crew member lacks required 'blob:read' permission")
410410+ // User is crew but doesn't have read or write permission
411411+ return nil, NewAuthError("blob:read", "crew member lacks permission", "blob:read", "blob:write")
369412 }
370413 }
371414372415 // User is neither owner nor authorized crew
373373- return nil, fmt.Errorf("user is not authorized for blob read (must be hold owner or crew with blob:read permission)")
416416+ return nil, NewAuthError("blob:read", "user is not a crew member", "blob:read", "blob:write")
374417}
375418376419// ServiceTokenClaims represents the claims in a service token JWT
···385428 // Extract Authorization header
386429 authHeader := r.Header.Get("Authorization")
387430 if authHeader == "" {
388388- return nil, fmt.Errorf("missing Authorization header")
431431+ return nil, ErrMissingAuthHeader
389432 }
390433391434 // Check for Bearer authorization scheme
392435 parts := strings.SplitN(authHeader, " ", 2)
393436 if len(parts) != 2 {
394394- return nil, fmt.Errorf("invalid Authorization header format")
437437+ return nil, ErrInvalidAuthFormat
395438 }
396439397440 if parts[0] != "Bearer" {
···400443401444 tokenString := parts[1]
402445 if tokenString == "" {
403403- return nil, fmt.Errorf("missing token")
446446+ return nil, ErrMissingToken
404447 }
405448406449 slog.Debug("Validating service token", "holdDID", holdDID)
···409452 // Split token: header.payload.signature
410453 tokenParts := strings.Split(tokenString, ".")
411454 if len(tokenParts) != 3 {
412412- return nil, fmt.Errorf("invalid JWT format")
455455+ return nil, ErrInvalidJWTFormat
413456 }
414457415458 // Decode payload (second part) to extract claims
···427470 // Get issuer (user DID)
428471 issuerDID := claims.Issuer
429472 if issuerDID == "" {
430430- return nil, fmt.Errorf("missing iss claim")
473473+ return nil, ErrMissingISSClaim
431474 }
432475433476 // Verify audience matches this hold service
···445488 return nil, fmt.Errorf("failed to get expiration: %w", err)
446489 }
447490 if exp != nil && time.Now().After(exp.Time) {
448448- return nil, fmt.Errorf("token has expired")
491491+ return nil, ErrTokenExpired
449492 }
450493451494 // Verify JWT signature using ATProto's secp256k1 crypto
+110
pkg/hold/pds/auth_test.go
···771771 }
772772}
773773774774+// TestValidateBlobReadAccess_BlobWriteImpliesRead tests that blob:write grants read access
775775+func TestValidateBlobReadAccess_BlobWriteImpliesRead(t *testing.T) {
776776+ ownerDID := "did:plc:owner123"
777777+778778+ pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, false, false)
779779+780780+ // Verify captain record has public=false (private hold)
781781+ _, captain, err := pds.GetCaptainRecord(ctx)
782782+ if err != nil {
783783+ t.Fatalf("Failed to get captain record: %v", err)
784784+ }
785785+786786+ if captain.Public {
787787+ t.Error("Expected public=false for captain record")
788788+ }
789789+790790+ // Add crew member with ONLY blob:write permission (no blob:read)
791791+ writerDID := "did:plc:writer123"
792792+ _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
793793+ if err != nil {
794794+ t.Fatalf("Failed to add crew writer: %v", err)
795795+ }
796796+797797+ mockClient := &mockPDSClient{}
798798+799799+ // Test writer (has only blob:write permission) can read
800800+ t.Run("crew with blob:write can read", func(t *testing.T) {
801801+ dpopHelper, err := NewDPoPTestHelper(writerDID, "https://test-pds.example.com")
802802+ if err != nil {
803803+ t.Fatalf("Failed to create DPoP helper: %v", err)
804804+ }
805805+806806+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
807807+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
808808+ t.Fatalf("Failed to add DPoP to request: %v", err)
809809+ }
810810+811811+ // This should SUCCEED because blob:write implies blob:read
812812+ user, err := ValidateBlobReadAccess(req, pds, mockClient)
813813+ if err != nil {
814814+ t.Errorf("Expected blob:write to grant read access, got error: %v", err)
815815+ }
816816+817817+ if user == nil {
818818+ t.Error("Expected user to be returned for valid read access")
819819+ } else if user.DID != writerDID {
820820+ t.Errorf("Expected user DID %s, got %s", writerDID, user.DID)
821821+ }
822822+ })
823823+824824+ // Also verify that crew with only blob:read still works
825825+ t.Run("crew with blob:read can read", func(t *testing.T) {
826826+ readerDID := "did:plc:reader123"
827827+ _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
828828+ if err != nil {
829829+ t.Fatalf("Failed to add crew reader: %v", err)
830830+ }
831831+832832+ dpopHelper, err := NewDPoPTestHelper(readerDID, "https://test-pds.example.com")
833833+ if err != nil {
834834+ t.Fatalf("Failed to create DPoP helper: %v", err)
835835+ }
836836+837837+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
838838+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
839839+ t.Fatalf("Failed to add DPoP to request: %v", err)
840840+ }
841841+842842+ user, err := ValidateBlobReadAccess(req, pds, mockClient)
843843+ if err != nil {
844844+ t.Errorf("Expected blob:read to grant read access, got error: %v", err)
845845+ }
846846+847847+ if user == nil {
848848+ t.Error("Expected user to be returned for valid read access")
849849+ } else if user.DID != readerDID {
850850+ t.Errorf("Expected user DID %s, got %s", readerDID, user.DID)
851851+ }
852852+ })
853853+854854+ // Verify crew with neither permission cannot read
855855+ t.Run("crew without read or write cannot read", func(t *testing.T) {
856856+ noPermDID := "did:plc:noperm123"
857857+ _, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"})
858858+ if err != nil {
859859+ t.Fatalf("Failed to add crew member: %v", err)
860860+ }
861861+862862+ dpopHelper, err := NewDPoPTestHelper(noPermDID, "https://test-pds.example.com")
863863+ if err != nil {
864864+ t.Fatalf("Failed to create DPoP helper: %v", err)
865865+ }
866866+867867+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
868868+ if err := dpopHelper.AddDPoPToRequest(req); err != nil {
869869+ t.Fatalf("Failed to add DPoP to request: %v", err)
870870+ }
871871+872872+ _, err = ValidateBlobReadAccess(req, pds, mockClient)
873873+ if err == nil {
874874+ t.Error("Expected error for crew without read or write permission")
875875+ }
876876+877877+ // Verify error message format
878878+ if !strings.Contains(err.Error(), "access denied for blob:read") {
879879+ t.Errorf("Expected structured error message, got: %v", err)
880880+ }
881881+ })
882882+}
883883+774884// TestValidateOwnerOrCrewAdmin tests admin permission checking
775885func TestValidateOwnerOrCrewAdmin(t *testing.T) {
776886 ownerDID := "did:plc:owner123"