···105105 COALESCE(m.artifact_type, 'container-image'),
106106 COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
107107 COALESCE(m.digest, ''),
108108- COALESCE(rs.last_push, m.created_at),
108108+ MAX(rs.last_push, m.created_at),
109109 COALESCE(rp.avatar_cid, '')
110110 FROM matching_repos mr
111111 JOIN manifests m ON mr.latest_id = m.id
···113113 JOIN repo_stats ON m.did = repo_stats.did AND m.repository = repo_stats.repository
114114 LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
115115 LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
116116- ORDER BY COALESCE(rs.last_push, m.created_at) DESC
116116+ ORDER BY MAX(rs.last_push, m.created_at) DESC
117117 LIMIT ? OFFSET ?
118118 `
119119···17431743 var orderBy string
17441744 switch sortOrder {
17451745 case SortByLastUpdate:
17461746- orderBy = "COALESCE(rs.last_push, m.created_at) DESC"
17461746+ orderBy = "MAX(rs.last_push, m.created_at) DESC"
17471747 default: // SortByScore
17481748 orderBy = "(COALESCE(rs.pull_count, 0) + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0) * 10) DESC, m.created_at DESC"
17491749 }
···17681768 COALESCE(m.artifact_type, 'container-image'),
17691769 COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
17701770 COALESCE(m.digest, ''),
17711771- COALESCE(rs.last_push, m.created_at),
17711771+ MAX(rs.last_push, m.created_at),
17721772 COALESCE(rp.avatar_cid, '')
17731773 FROM latest_manifests lm
17741774 JOIN manifests m ON lm.latest_id = m.id
···18411841 COALESCE(m.artifact_type, 'container-image'),
18421842 COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
18431843 COALESCE(m.digest, ''),
18441844- COALESCE(rs.last_push, m.created_at),
18441844+ MAX(rs.last_push, m.created_at),
18451845 COALESCE(rp.avatar_cid, '')
18461846 FROM latest_manifests lm
18471847 JOIN manifests m ON lm.latest_id = m.id
18481848 JOIN users u ON m.did = u.did
18491849 LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
18501850 LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
18511851- ORDER BY COALESCE(rs.last_push, m.created_at) DESC
18511851+ ORDER BY MAX(rs.last_push, m.created_at) DESC
18521852 `
1853185318541854 rows, err := db.Query(query, userDID, currentUserDID)
+5-2
pkg/appview/server.go
···647647 next.ServeHTTP(w, r)
648648649649 case regDomains[host]:
650650- // Registry domain: allow /v2/*, redirect everything else
651651- if isV2 {
650650+ // Registry domain: allow /v2/*, /auth/token, /auth/device/*, redirect everything else
651651+ // Auth endpoints must be served directly to avoid 307 redirects that strip
652652+ // the Authorization header on cross-host redirects (Go http.Client behavior).
653653+ isAuth := path == "/auth/token" || strings.HasPrefix(path, "/auth/device/")
654654+ if isV2 || isAuth {
652655 next.ServeHTTP(w, r)
653656 return
654657 }
+6-1
pkg/hold/pds/auth.go
···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.
357357// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
358358-func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
358358+// If scannerSecret is non-empty, a Bearer token matching it grants full read access (for scanner blob fetches).
359359+func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient, scannerSecret string) (*ValidatedUser, error) {
359360 // Get captain record to check public setting
360361 _, captain, err := pds.GetCaptainRecord(r.Context())
361362 if err != nil {
···372373 var user *ValidatedUser
373374374375 if strings.HasPrefix(authHeader, "Bearer ") {
376376+ // Check if this is a scanner using the shared secret
377377+ if scannerSecret != "" && strings.TrimPrefix(authHeader, "Bearer ") == scannerSecret {
378378+ return &ValidatedUser{DID: "scanner"}, nil
379379+ }
375380 // Service token authentication (from AppView via getServiceAuth)
376381 user, err = ValidateServiceToken(r, pds.did, httpClient)
377382 if err != nil {
+5-5
pkg/hold/pds/auth_test.go
···724724 req := httptest.NewRequest(http.MethodGet, "/test", nil)
725725726726 // This should return nil (public access allowed) for public holds
727727- user, err := ValidateBlobReadAccess(req, pds, nil)
727727+ user, err := ValidateBlobReadAccess(req, pds, nil, "")
728728 if err != nil {
729729 t.Errorf("Expected public access for public hold, got error: %v", err)
730730 }
···768768 req := httptest.NewRequest(http.MethodGet, "/test", nil)
769769770770 // This should return error (auth required) for private holds
771771- user, err := ValidateBlobReadAccess(req, pds, nil)
771771+ user, err := ValidateBlobReadAccess(req, pds, nil, "")
772772 if err == nil {
773773 t.Error("Expected error for private hold without auth")
774774 }
···816816 }
817817818818 // This should SUCCEED because blob:write implies blob:read
819819- user, err := ValidateBlobReadAccess(req, pds, mockClient)
819819+ user, err := ValidateBlobReadAccess(req, pds, mockClient, "")
820820 if err != nil {
821821 t.Errorf("Expected blob:write to grant read access, got error: %v", err)
822822 }
···846846 t.Fatalf("Failed to add DPoP to request: %v", err)
847847 }
848848849849- user, err := ValidateBlobReadAccess(req, pds, mockClient)
849849+ user, err := ValidateBlobReadAccess(req, pds, mockClient, "")
850850 if err != nil {
851851 t.Errorf("Expected blob:read to grant read access, got error: %v", err)
852852 }
···876876 t.Fatalf("Failed to add DPoP to request: %v", err)
877877 }
878878879879- _, err = ValidateBlobReadAccess(req, pds, mockClient)
879879+ _, err = ValidateBlobReadAccess(req, pds, mockClient, "")
880880 if err == nil {
881881 t.Error("Expected error for crew without read or write permission")
882882 }
+25
pkg/hold/pds/events.go
···415415 // else cursor == currentSeq: relay is caught up, just stream new events
416416 }
417417418418+ // Start read pump to handle pings/pongs and detect disconnects.
419419+ // gorilla/websocket requires an active reader to process control frames;
420420+ // without one, pings go unanswered and the relay times out the connection.
421421+ go b.readPump(sub)
422422+418423 // Start goroutine to handle sending events to this subscriber
419424 go b.handleSubscriber(sub)
420425···682687 slog.Warn("Backfill timeout for subscriber", "seq", he.Seq)
683688 return
684689 }
690690+ }
691691+ }
692692+}
693693+694694+// readPump reads from the WebSocket to process control frames (ping/pong/close).
695695+// gorilla/websocket automatically responds to pings with pongs when there is an
696696+// active reader. Without this, relays time out the connection.
697697+func (b *EventBroadcaster) readPump(sub *Subscriber) {
698698+ defer func() {
699699+ b.Unsubscribe(sub)
700700+ sub.conn.Close()
701701+ }()
702702+703703+ for {
704704+ _, _, err := sub.conn.ReadMessage()
705705+ if err != nil {
706706+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
707707+ slog.Warn("Firehose subscriber disconnected", "remote", sub.conn.RemoteAddr(), "error", err)
708708+ }
709709+ return
685710 }
686711 }
687712}
+34-7
pkg/hold/pds/scan_broadcaster.go
···9494 return nil, fmt.Errorf("failed to ping scan jobs database: %w", err)
9595 }
96969797+ // Set WAL mode and busy timeout (libsql PRAGMAs return rows)
9898+ var journalMode string
9999+ if err := db.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil {
100100+ db.Close()
101101+ return nil, fmt.Errorf("failed to set journal mode: %w", err)
102102+ }
103103+ var busyTimeout int
104104+ if err := db.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil {
105105+ db.Close()
106106+ return nil, fmt.Errorf("failed to set busy_timeout: %w", err)
107107+ }
108108+97109 sb := &ScanBroadcaster{
98110 subscribers: make([]*ScanSubscriber, 0),
99111 db: db,
···509521 "error", msg.Error)
510522}
511523512512-// drainPendingJobs sends pending/timed-out jobs to a newly connected scanner
524524+// drainPendingJobs sends pending/timed-out jobs to a newly connected scanner.
525525+// Collects all pending rows first, closes cursor, then assigns and dispatches
526526+// to avoid holding a SELECT cursor open during UPDATEs (prevents SQLite BUSY).
513527func (sb *ScanBroadcaster) drainPendingJobs(sub *ScanSubscriber, cursor int64) {
514528 rows, err := sb.db.Query(`
515529 SELECT seq, manifest_digest, repository, tag, user_did, user_handle, hold_did, hold_endpoint, tier, config_json, layers_json
···521535 slog.Error("Failed to drain pending scan jobs", "error", err)
522536 return
523537 }
524524- defer rows.Close()
525538526526- count := 0
539539+ var jobs []*ScanJobEvent
527540 for rows.Next() {
528541 job := &ScanJobEvent{Type: "job"}
529542 var configJSON, layersJSON string
···540553541554 job.Config = json.RawMessage(configJSON)
542555 job.Layers = json.RawMessage(layersJSON)
556556+ jobs = append(jobs, job)
557557+ }
558558+ rows.Close()
543559544544- // Assign and dispatch
560560+ count := 0
561561+ for _, job := range jobs {
545562 _, err = sb.db.Exec(`
546563 UPDATE scan_jobs SET status = 'assigned', assigned_to = ?, assigned_at = ?
547564 WHERE seq = ? AND status = 'pending'
···578595 }
579596}
580597581581-// reDispatchTimedOut finds jobs that were assigned but not acked/completed within timeout
598598+// reDispatchTimedOut finds jobs that were assigned but not acked/completed within timeout.
599599+// Collects timed-out rows first, closes cursor, then resets and re-dispatches
600600+// to avoid holding a SELECT cursor open during UPDATEs (prevents SQLite BUSY).
582601func (sb *ScanBroadcaster) reDispatchTimedOut() {
583602 timeout := time.Now().Add(-sb.ackTimeout)
584603···592611 slog.Error("Failed to query timed-out scan jobs", "error", err)
593612 return
594613 }
595595- defer rows.Close()
596614615615+ var jobs []*ScanJobEvent
597616 for rows.Next() {
598617 job := &ScanJobEvent{Type: "job"}
599618 var configJSON, layersJSON string
···609628610629 job.Config = json.RawMessage(configJSON)
611630 job.Layers = json.RawMessage(layersJSON)
631631+ jobs = append(jobs, job)
632632+ }
633633+ rows.Close()
612634613613- // Reset to pending and re-dispatch
635635+ for _, job := range jobs {
614636 _, err = sb.db.Exec(`
615637 UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL
616638 WHERE seq = ?
···633655 return sb.db.Close()
634656 }
635657 return nil
658658+}
659659+660660+// Secret returns the scanner shared secret for use in blob read authorization
661661+func (sb *ScanBroadcaster) Secret() string {
662662+ return sb.secret
636663}
637664638665// ValidateScannerSecret checks if the provided secret matches