Select the types of activity you want to include in your feed.
big scary refactor. sync enable_bluesky_posts with captain record. implement oauth logout handler. implement crew assignment to hold. this caused a lot of circular dependencies and needed to move functions around in order to fix
···89899090# Enable Bluesky posts when users push container images (default: false)
9191# When enabled, the hold's embedded PDS will create posts announcing image pushes
9292-# Can be overridden per-hold via the captain record's enableManifestPosts field
9292+# Synced to captain record's enableBlueskyPosts field on startup
9393# HOLD_BLUESKY_POSTS_ENABLED=false
94949595# ==============================================================================
+146-18
cmd/appview/serve.go
···99 "net/http"
1010 "os"
1111 "os/signal"
1212+ "strings"
1213 "syscall"
1314 "time"
14151616+ "github.com/bluesky-social/indigo/atproto/syntax"
1517 "github.com/distribution/distribution/v3/configuration"
1618 "github.com/distribution/distribution/v3/registry"
1719 "github.com/distribution/distribution/v3/registry/handlers"
1820 "github.com/spf13/cobra"
19212022 "atcr.io/pkg/appview/middleware"
2323+ "atcr.io/pkg/appview/storage"
2424+ "atcr.io/pkg/atproto"
2125 "atcr.io/pkg/auth"
2226 "atcr.io/pkg/auth/oauth"
2327 "atcr.io/pkg/auth/token"
···206210 fmt.Println("README cache initialized for manifest push refresh")
207211208212 // Initialize UI routes with OAuth app, refresher, device store, health checker, and readme cache
209209- uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache)
213213+ uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, oauthStore, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache)
210214211215 // Create OAuth server
212216 oauthServer := oauth.NewServer(oauthApp)
···216220 if uiSessionStore != nil {
217221 oauthServer.SetUISessionStore(uiSessionStore)
218222 }
219219- // Connect database for user avatar management
220220- oauthServer.SetDatabase(uiDatabase)
223223+224224+ // Register OAuth post-auth callback for AppView business logic
225225+ // This decouples the OAuth package from AppView-specific dependencies
226226+ oauthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error {
227227+ fmt.Printf("DEBUG [appview/callback]: OAuth post-auth callback for DID=%s\n", did)
228228+229229+ // Parse DID for session resume
230230+ didParsed, err := syntax.ParseDID(did)
231231+ if err != nil {
232232+ fmt.Printf("WARNING [appview/callback]: Failed to parse DID %s: %v\n", did, err)
233233+ return nil // Non-fatal
234234+ }
235235+236236+ // Resume OAuth session to get authenticated client
237237+ session, err := oauthApp.ResumeSession(ctx, didParsed, sessionID)
238238+ if err != nil {
239239+ fmt.Printf("WARNING [appview/callback]: Failed to resume session for DID=%s: %v\n", did, err)
240240+ // Fallback: update user without avatar
241241+ _ = db.UpsertUser(uiDatabase, &db.User{
242242+ DID: did,
243243+ Handle: handle,
244244+ PDSEndpoint: pdsEndpoint,
245245+ Avatar: "",
246246+ LastSeen: time.Now(),
247247+ })
248248+ return nil // Non-fatal
249249+ }
221250222222- // Set default hold DID on OAuth server (extracted earlier)
223223- // This is used to create sailor profiles on first login
224224- if defaultHoldDID != "" {
225225- oauthServer.SetDefaultHoldDID(defaultHoldDID)
226226- fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldDID)
227227- }
251251+ // Create authenticated atproto client using the indigo session's API client
252252+ client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
253253+254254+ // Ensure sailor profile exists (creates with default hold if configured)
255255+ fmt.Printf("DEBUG [appview/callback]: Ensuring profile exists for %s (defaultHold=%s)\n", did, defaultHoldDID)
256256+ if err := storage.EnsureProfile(ctx, client, defaultHoldDID); err != nil {
257257+ fmt.Printf("WARNING [appview/callback]: Failed to ensure profile for %s: %v\n", did, err)
258258+ // Continue anyway - profile creation is not critical for avatar fetch
259259+ } else {
260260+ fmt.Printf("DEBUG [appview/callback]: Profile ensured for %s\n", did)
261261+ }
262262+263263+ // Fetch user's profile record from PDS (contains blob references)
264264+ profileRecord, err := client.GetProfileRecord(ctx, did)
265265+ if err != nil {
266266+ fmt.Printf("WARNING [appview/callback]: Failed to fetch profile record for DID=%s: %v\n", did, err)
267267+ // Still update user without avatar
268268+ _ = db.UpsertUser(uiDatabase, &db.User{
269269+ DID: did,
270270+ Handle: handle,
271271+ PDSEndpoint: pdsEndpoint,
272272+ Avatar: "",
273273+ LastSeen: time.Now(),
274274+ })
275275+ return nil // Non-fatal
276276+ }
277277+278278+ // Construct avatar URL from blob CID using imgs.blue CDN
279279+ var avatarURL string
280280+ if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
281281+ avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link)
282282+ fmt.Printf("DEBUG [appview/callback]: Constructed avatar URL: %s\n", avatarURL)
283283+ }
284284+285285+ // Store user with avatar in database
286286+ err = db.UpsertUser(uiDatabase, &db.User{
287287+ DID: did,
288288+ Handle: handle,
289289+ PDSEndpoint: pdsEndpoint,
290290+ Avatar: avatarURL,
291291+ LastSeen: time.Now(),
292292+ })
293293+ if err != nil {
294294+ fmt.Printf("WARNING [appview/callback]: Failed to store user in database: %v\n", err)
295295+ return nil // Non-fatal
296296+ }
297297+298298+ fmt.Printf("DEBUG [appview/callback]: Stored user with avatar for DID=%s\n", did)
299299+300300+ // Migrate profile URL→DID if needed
301301+ profile, err := storage.GetProfile(ctx, client)
302302+ if err != nil {
303303+ fmt.Printf("WARNING [appview/callback]: Failed to get profile for %s: %v\n", did, err)
304304+ return nil // Non-fatal
305305+ }
306306+307307+ var holdDID string
308308+ if profile != nil && profile.DefaultHold != "" {
309309+ // Check if defaultHold is a URL (needs migration)
310310+ if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
311311+ fmt.Printf("DEBUG [appview/callback]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold)
312312+313313+ // Resolve URL to DID
314314+ holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
315315+316316+ // Update profile with DID
317317+ profile.DefaultHold = holdDID
318318+ if err := storage.UpdateProfile(ctx, client, profile); err != nil {
319319+ fmt.Printf("WARNING [appview/callback]: Failed to update profile with hold DID for %s: %v\n", did, err)
320320+ } else {
321321+ fmt.Printf("DEBUG [appview/callback]: Updated profile with hold DID: %s\n", holdDID)
322322+ }
323323+ fmt.Printf("DEBUG [oauth/server]: Attempting crew registration for %s at hold %s\n", did, holdDID)
324324+ storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
325325+ } else {
326326+ // Already a DID - use it
327327+ holdDID = profile.DefaultHold
328328+ }
329329+ // Register crew regardless of migration (outside the migration block)
330330+ fmt.Printf("DEBUG [appview/callback]: Attempting crew registration for %s at hold %s\n", did, holdDID)
331331+ storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
332332+333333+ }
334334+335335+ return nil // All errors are non-fatal, logged for debugging
336336+ })
228337229338 // Initialize auth keys and create token issuer
230339 var issuer *token.Issuer
···284393 // Mount auth endpoints if enabled
285394 if issuer != nil {
286395 // Basic Auth token endpoint (supports device secrets and app passwords)
287287- // Reuse defaultHoldDID extracted earlier
288288- tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldDID)
396396+ tokenHandler := token.NewHandler(issuer, deviceStore)
397397+398398+ // Register token post-auth callback for profile management
399399+ // This decouples the token package from AppView-specific dependencies
400400+ tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
401401+ fmt.Printf("DEBUG [appview/callback]: Token post-auth callback for DID=%s\n", did)
402402+403403+ // Create ATProto client with validated token
404404+ atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
405405+406406+ // Ensure profile exists (will create with default hold if not exists and default is configured)
407407+ if err := storage.EnsureProfile(ctx, atprotoClient, defaultHoldDID); err != nil {
408408+ // Log error but don't fail auth - profile management is not critical
409409+ fmt.Printf("WARNING [appview/callback]: Failed to ensure profile for %s: %v\n", did, err)
410410+ } else {
411411+ fmt.Printf("DEBUG [appview/callback]: Profile ensured for %s with default hold %s\n", did, defaultHoldDID)
412412+ }
413413+414414+ return nil // All errors are non-fatal
415415+ })
416416+289417 tokenHandler.RegisterRoutes(mux)
290418291419 // Device authorization endpoints (public)
···401529// readOnlyDB: read-only connection for public queries (search, user pages, etc.)
402530// defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io")
403531// healthChecker: hold endpoint health checker
404404-func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker, readmeCache *readme.Cache) (*template.Template, *mux.Router) {
532532+func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, oauthStore *db.OAuthStore, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker, readmeCache *readme.Cache) (*template.Template, *mux.Router) {
405533 // Check if UI is enabled
406534 uiEnabled := os.Getenv("ATCR_UI_ENABLED")
407535 if uiEnabled == "false" {
···582710 }).Methods("DELETE")
583711584712 // Logout endpoint (supports both GET and POST)
585585- router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
586586- if sessionID, ok := db.GetSessionID(r); ok {
587587- sessionStore.Delete(sessionID)
588588- }
589589- db.ClearCookie(w)
590590- http.Redirect(w, r, "/", http.StatusFound)
713713+ // Properly revokes OAuth tokens on PDS side before clearing local session
714714+ router.Handle("/auth/logout", &uihandlers.LogoutHandler{
715715+ OAuthApp: oauthApp,
716716+ Refresher: refresher,
717717+ SessionStore: sessionStore,
718718+ OAuthStore: oauthStore,
591719 }).Methods("GET", "POST")
592720593721 // Start Jetstream worker
+9-8
docs/BLUESKY_MANIFEST_POSTS.md
···694694```bash
695695# Enable/disable Bluesky manifest posting (default: false)
696696# When enabled, hold will create Bluesky posts when users push images
697697-# Can be overridden per-hold via captain record's enableManifestPosts field
697697+# Synced to captain record's enableBlueskyPosts field on startup
698698HOLD_BLUESKY_POSTS_ENABLED=false
699699```
700700···702702703703### Feature Flags
704704705705-**Captain Record Override:**
706706-The hold's captain record includes an `enableManifestPosts` field that overrides the environment variable:
705705+**Captain Record Sync:**
706706+The hold's captain record includes an `enableBlueskyPosts` field that is synchronized with the environment variable on startup:
707707708708```go
709709type CaptainRecord struct {
710710 // ... other fields ...
711711- EnableManifestPosts bool `json:"enableManifestPosts" cborgen:"enableManifestPosts"`
711711+ EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"`
712712}
713713```
714714715715-**Precedence (highest to lowest):**
716716-1. Captain record `enableManifestPosts` field (if set)
717717-2. `HOLD_BLUESKY_POSTS_ENABLED` environment variable
718718-3. Default: `false` (opt-in feature)
715715+**How it works:**
716716+1. On startup, Bootstrap reads `HOLD_BLUESKY_POSTS_ENABLED` environment variable
717717+2. Creates or updates the captain record to match the env var setting
718718+3. At runtime, the code reads from the captain record (which reflects the env var)
719719+4. To change the setting, update the env var and restart the hold
719720720721**Rationale:**
721722- Default off for backward compatibility and privacy
···1010 "sync"
1111 "time"
12121313- "atcr.io/pkg/appview"
1313+ "atcr.io/pkg/atproto"
1414)
15151616// HealthStatus represents the health status of a hold endpoint
···5353 // Convert DID to HTTP URL if needed
5454 // did:web:hold.example.com → https://hold.example.com
5555 // https://hold.example.com → https://hold.example.com (passthrough)
5656- httpURL := appview.ResolveHoldURL(endpoint)
5656+ httpURL := atproto.ResolveHoldURL(endpoint)
57575858 // Build health check URL
5959 healthURL := httpURL + "/xrpc/_health"
+1-2
pkg/appview/jetstream/backfill.go
···10101111 "github.com/bluesky-social/indigo/atproto/syntax"
12121313- "atcr.io/pkg/appview"
1413 "atcr.io/pkg/appview/db"
1514 "atcr.io/pkg/atproto"
1615)
···327326 }
328327329328 // Resolve hold DID to URL
330330- holdURL := appview.ResolveHoldURL(holdDID)
329329+ holdURL := atproto.ResolveHoldURL(holdDID)
331330332331 // Create client for hold's PDS
333332 holdClient := atproto.NewClient(holdURL, holdDID, "")
+22-96
pkg/appview/middleware/registry.go
···44 "context"
55 "encoding/json"
66 "fmt"
77- "io"
88- "net/http"
99- "net/url"
107 "strings"
118 "sync"
1212- "time"
1391410 "github.com/bluesky-social/indigo/atproto/identity"
1511 "github.com/bluesky-social/indigo/atproto/syntax"
···7369 distribution.Namespace
7470 directory identity.Directory
7571 defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
7272+ baseURL string // Base URL for error messages (e.g., "https://atcr.io")
7673 testMode bool // If true, fallback to default hold when user's hold is unreachable
7774 repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
7875 refresher *oauth.Refresher // OAuth session manager (copied from global on init)
···9390 defaultHoldDID = holdDID
9491 }
95929393+ // Get base URL from config (for error messages)
9494+ baseURL := ""
9595+ if url, ok := options["base_url"].(string); ok {
9696+ baseURL = url
9797+ }
9898+9699 // Check test mode from options (passed via env var)
97100 testMode := false
98101 if tm, ok := options["test_mode"].(bool); ok {
···105108 Namespace: ns,
106109 directory: directory,
107110 defaultHoldDID: defaultHoldDID,
111111+ baseURL: baseURL,
108112 testMode: testMode,
109113 refresher: globalRefresher,
110114 database: globalDatabase,
···113117 }, nil
114118}
115119120120+// authErrorMessage creates a user-friendly auth error with login URL
121121+func (nr *NamespaceResolver) authErrorMessage(message string) error {
122122+ loginURL := fmt.Sprintf("%s/auth/oauth/login", nr.baseURL)
123123+ fullMessage := fmt.Sprintf("%s - please re-authenticate at %s", message, loginURL)
124124+ return errcode.ErrorCodeUnauthorized.WithMessage(fullMessage)
125125+}
126126+116127// Repository resolves the repository name and delegates to underlying namespace
117128// Handles names like:
118129// - atcr.io/alice/myimage → resolve alice to DID
···160171 ctx = context.WithValue(ctx, holdDIDKey, holdDID)
161172162173 // Get service token for hold authentication
163163- // Check cache first to avoid unnecessary PDS calls on every request
164174 var serviceToken string
165175 if nr.refresher != nil {
166166- cachedToken, expiresAt := token.GetServiceToken(did, holdDID)
167167-168168- // Use cached token if it exists and has > 10s remaining
169169- if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
170170- fmt.Printf("DEBUG [registry/middleware]: Using cached service token for DID=%s (expires in %v)\n",
171171- did, time.Until(expiresAt).Round(time.Second))
172172- serviceToken = cachedToken
173173- } else {
174174- // Cache miss or expiring soon - validate OAuth and get new service token
175175- if cachedToken == "" {
176176- fmt.Printf("DEBUG [registry/middleware]: Cache miss, fetching service token for DID=%s\n", did)
177177- } else {
178178- fmt.Printf("DEBUG [registry/middleware]: Token expiring soon, proactively renewing for DID=%s\n", did)
179179- }
180180-181181- session, err := nr.refresher.GetSession(ctx, did)
182182- if err != nil {
183183- // OAuth session unavailable - fail fast with proper auth error
184184- nr.refresher.InvalidateSession(did)
185185- token.InvalidateServiceToken(did, holdDID)
186186- fmt.Printf("ERROR [registry/middleware]: Failed to get OAuth session for DID=%s: %v\n", did, err)
187187- fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
188188- return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
189189- }
190190-191191- // Call com.atproto.server.getServiceAuth on the user's PDS
192192- // Request 5-minute expiry (PDS may grant less)
193193- // exp must be absolute Unix timestamp, not relative duration
194194- // Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID
195195- expiryTime := time.Now().Unix() + 300 // 5 minutes from now
196196- serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
197197- pdsEndpoint,
198198- atproto.ServerGetServiceAuth,
199199- url.QueryEscape(holdDID),
200200- url.QueryEscape("com.atproto.repo.getRecord"),
201201- expiryTime,
202202- )
203203-204204- req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
205205- if err != nil {
206206- fmt.Printf("ERROR [registry/middleware]: Failed to create service auth request: %v\n", err)
207207- return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
208208- }
209209-210210- // Use OAuth session to authenticate to PDS (with DPoP)
211211- resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
212212- if err != nil {
213213- // Invalidate session on auth errors (may indicate corrupted session or expired tokens)
214214- nr.refresher.InvalidateSession(did)
215215- token.InvalidateServiceToken(did, holdDID)
216216- fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: %v\n", did, err)
217217- fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
218218- return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
219219- }
220220- defer resp.Body.Close()
221221-222222- if resp.StatusCode != http.StatusOK {
223223- // Invalidate session on auth failures
224224- bodyBytes, _ := io.ReadAll(resp.Body)
225225- nr.refresher.InvalidateSession(did)
226226- token.InvalidateServiceToken(did, holdDID)
227227- fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: status %d, body: %s\n",
228228- did, resp.StatusCode, string(bodyBytes))
229229- fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
230230- return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
231231- }
232232-233233- // Parse response to get service token
234234- var result struct {
235235- Token string `json:"token"`
236236- }
237237- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
238238- fmt.Printf("ERROR [registry/middleware]: Failed to decode service auth response: %v\n", err)
239239- return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
240240- }
241241-242242- if result.Token == "" {
243243- fmt.Printf("ERROR [registry/middleware]: Empty token in service auth response\n")
244244- return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
245245- }
246246-247247- serviceToken = result.Token
248248-249249- // Cache the token (parses JWT to extract actual expiry)
250250- if err := token.SetServiceToken(did, holdDID, serviceToken); err != nil {
251251- fmt.Printf("WARN [registry/middleware]: Failed to cache service token: %v\n", err)
252252- // Non-fatal - we have the token, just won't be cached
253253- }
254254-255255- fmt.Printf("DEBUG [registry/middleware]: OAuth validation succeeded for DID=%s\n", did)
176176+ var err error
177177+ serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
178178+ if err != nil {
179179+ fmt.Printf("ERROR [registry/middleware]: Failed to get service token for DID=%s: %v\n", did, err)
180180+ fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
181181+ return nil, nr.authErrorMessage("OAuth session expired")
256182 }
257183 }
258184···366292 client := atproto.NewClient(pdsEndpoint, did, "")
367293368294 // Check for sailor profile
369369- profile, err := atproto.GetProfile(ctx, client)
295295+ profile, err := storage.GetProfile(ctx, client)
370296 if err != nil {
371297 // Error reading profile (not a 404) - log and continue
372298 fmt.Printf("WARNING: failed to read profile for %s: %v\n", did, err)
+82
pkg/appview/storage/crew.go
···11+package storage
22+33+import (
44+ "context"
55+ "fmt"
66+ "log/slog"
77+ "net/http"
88+99+ "atcr.io/pkg/atproto"
1010+ "atcr.io/pkg/auth/oauth"
1111+ "atcr.io/pkg/auth/token"
1212+)
1313+1414+// EnsureCrewMembership attempts to register the user as a crew member on their default hold.
1515+// The hold's requestCrew endpoint handles all authorization logic (checking allowAllCrew, existing membership, etc).
1616+// This is best-effort and does not fail on errors.
1717+func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher *oauth.Refresher, defaultHoldDID string) {
1818+ if defaultHoldDID == "" {
1919+ return
2020+ }
2121+2222+ // Normalize URL to DID if needed
2323+ holdDID := atproto.ResolveHoldDIDFromURL(defaultHoldDID)
2424+ if holdDID == "" {
2525+ slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID)
2626+ return
2727+ }
2828+2929+ // Resolve hold DID to HTTP endpoint
3030+ holdEndpoint := atproto.ResolveHoldURL(holdDID)
3131+3232+ // Get service token for the hold
3333+ // Only works with OAuth (refresher required) - app passwords can't get service tokens
3434+ if refresher == nil {
3535+ slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID)
3636+ return
3737+ }
3838+3939+ // Wrap the refresher to match OAuthSessionRefresher interface
4040+ serviceToken, err := token.GetOrFetchServiceToken(ctx, refresher, client.DID(), holdDID, client.PDSEndpoint())
4141+ if err != nil {
4242+ slog.Warn("failed to get service token", "holdDID", holdDID, "error", err)
4343+ return
4444+ }
4545+4646+ // Call requestCrew endpoint - it handles all the logic:
4747+ // - Checks allowAllCrew flag
4848+ // - Checks if already a crew member (returns success if so)
4949+ // - Creates crew record if authorized
5050+ if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil {
5151+ slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err)
5252+ return
5353+ }
5454+5555+ slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", client.DID())
5656+}
5757+5858+// requestCrewMembership calls the hold's requestCrew endpoint
5959+// The endpoint handles all authorization and duplicate checking internally
6060+func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error {
6161+ url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew)
6262+6363+ req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
6464+ if err != nil {
6565+ return err
6666+ }
6767+6868+ req.Header.Set("Authorization", "Bearer "+serviceToken)
6969+ req.Header.Set("Content-Type", "application/json")
7070+7171+ resp, err := http.DefaultClient.Do(req)
7272+ if err != nil {
7373+ return err
7474+ }
7575+ defer resp.Body.Close()
7676+7777+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
7878+ return fmt.Errorf("requestCrew failed with status %d", resp.StatusCode)
7979+ }
8080+8181+ return nil
8282+}
+1-2
pkg/appview/storage/proxy_blob_store.go
···1010 "sync"
1111 "time"
12121313- "atcr.io/pkg/appview"
1413 "atcr.io/pkg/atproto"
1514 "github.com/distribution/distribution/v3"
1615 "github.com/distribution/distribution/v3/registry/api/errcode"
···4039// NewProxyBlobStore creates a new proxy blob store
4140func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
4241 // Resolve DID to URL once at construction time
4343- holdURL := appview.ResolveHoldURL(ctx.HoldDID)
4242+ holdURL := atproto.ResolveHoldURL(ctx.HoldDID)
44434544 fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with holdDID=%s, holdURL=%s, userDID=%s, repo=%s\n",
4645 ctx.HoldDID, holdURL, ctx.DID, ctx.Repository)
+1-2
pkg/appview/storage/proxy_blob_store_test.go
···1111 "testing"
1212 "time"
13131414- "atcr.io/pkg/appview"
1514 "atcr.io/pkg/atproto"
1615 "atcr.io/pkg/auth/token"
1716 "github.com/opencontainers/go-digest"
···219218220219 for _, tt := range tests {
221220 t.Run(tt.name, func(t *testing.T) {
222222- result := appview.ResolveHoldURL(tt.holdDID)
221221+ result := atproto.ResolveHoldURL(tt.holdDID)
223222 if result != tt.expected {
224223 t.Errorf("Expected %s, got %s", tt.expected, result)
225224 }
+3-3
pkg/appview/utils.go
pkg/atproto/utils.go
···11-package appview
11+package atproto
2233import "strings"
44···1414 }
15151616 // If it's a DID, convert to URL
1717- if strings.HasPrefix(holdIdentifier, "did:web:") {
1818- hostname := strings.TrimPrefix(holdIdentifier, "did:web:")
1717+ if after, ok := strings.CutPrefix(holdIdentifier, "did:web:"); ok {
1818+ hostname := after
19192020 // Use HTTP for localhost/IP addresses with ports, HTTPS for domains
2121 if strings.Contains(hostname, ":") ||
+6-2
pkg/appview/utils_test.go
···11package appview
2233-import "testing"
33+import (
44+ "testing"
55+66+ "atcr.io/pkg/atproto"
77+)
4859func TestResolveHoldURL(t *testing.T) {
610 tests := []struct {
···52565357 for _, tt := range tests {
5458 t.Run(tt.name, func(t *testing.T) {
5555- result := ResolveHoldURL(tt.input)
5959+ result := atproto.ResolveHoldURL(tt.input)
5660 if result != tt.expected {
5761 t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.input, result, tt.expected)
5862 }
+10-10
pkg/atproto/cbor_gen.go
···467467 return err
468468 }
469469470470- // t.EnableManifestPosts (bool) (bool)
471471- if len("enableManifestPosts") > 8192 {
472472- return xerrors.Errorf("Value in field \"enableManifestPosts\" was too long")
470470+ // t.EnableBlueskyPosts (bool) (bool)
471471+ if len("enableBlueskyPosts") > 8192 {
472472+ return xerrors.Errorf("Value in field \"enableBlueskyPosts\" was too long")
473473 }
474474475475- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("enableManifestPosts"))); err != nil {
475475+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("enableBlueskyPosts"))); err != nil {
476476 return err
477477 }
478478- if _, err := cw.WriteString(string("enableManifestPosts")); err != nil {
478478+ if _, err := cw.WriteString(string("enableBlueskyPosts")); err != nil {
479479 return err
480480 }
481481482482- if err := cbg.WriteBool(w, t.EnableManifestPosts); err != nil {
482482+ if err := cbg.WriteBool(w, t.EnableBlueskyPosts); err != nil {
483483 return err
484484 }
485485 return nil
···617617 default:
618618 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
619619 }
620620- // t.EnableManifestPosts (bool) (bool)
621621- case "enableManifestPosts":
620620+ // t.EnableBlueskyPosts (bool) (bool)
621621+ case "enableBlueskyPosts":
622622623623 maj, extra, err = cr.ReadHeader()
624624 if err != nil {
···629629 }
630630 switch extra {
631631 case 20:
632632- t.EnableManifestPosts = false
632632+ t.EnableBlueskyPosts = false
633633 case 21:
634634- t.EnableManifestPosts = true
634634+ t.EnableBlueskyPosts = true
635635 default:
636636 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
637637 }
···434434}
435435436436// isDID checks if a string is a DID (starts with "did:")
437437-func isDID(s string) bool {
437437+func IsDID(s string) bool {
438438 return len(s) > 4 && s[:4] == "did:"
439439}
440440···536536// Stored in the hold's embedded PDS to identify the hold owner and settings
537537// Uses CBOR encoding for efficient storage in hold's carstore
538538type CaptainRecord struct {
539539- Type string `json:"$type" cborgen:"$type"`
540540- Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
541541- Public bool `json:"public" cborgen:"public"` // Public read access
542542- AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
543543- EnableManifestPosts bool `json:"enableManifestPosts" cborgen:"enableManifestPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
544544- DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
545545- Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
546546- Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
539539+ Type string `json:"$type" cborgen:"$type"`
540540+ Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
541541+ Public bool `json:"public" cborgen:"public"` // Public read access
542542+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
543543+ EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
544544+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
545545+ Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
546546+ Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
547547}
548548549549// CrewRecord represents a crew member in the hold
···2233import (
44 "context"
55- "database/sql"
65 "fmt"
76 "html/template"
87 "net/http"
98 "strings"
109 "time"
1111-1212- "atcr.io/pkg/appview/db"
1313- "atcr.io/pkg/atproto"
1414- indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
1515- "github.com/bluesky-social/indigo/atproto/syntax"
1610)
17111812// UISessionStore is the interface for UI session management
···2317 UpsertUser(did, handle, pdsEndpoint, avatar string) error
2418}
25192020+// PostAuthCallback is called after successful OAuth authentication.
2121+// Parameters: ctx, did, handle, pdsEndpoint, sessionID
2222+// This allows AppView to perform business logic (profile creation, avatar fetch, etc.)
2323+// without coupling the OAuth package to AppView-specific dependencies.
2424+type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error
2525+2626// Server handles OAuth authorization for the AppView
2727type Server struct {
2828- app *App
2929- refresher *Refresher
3030- uiSessionStore UISessionStore
3131- db *sql.DB
3232- defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
2828+ app *App
2929+ refresher *Refresher
3030+ uiSessionStore UISessionStore
3131+ postAuthCallback PostAuthCallback
3332}
34333534// NewServer creates a new OAuth server
···3938 }
4039}
41404242-// SetDefaultHoldDID sets the default hold DID for profile creation
4343-// Expected format: "did:web:hold01.atcr.io"
4444-// To find a hold's DID, visit: https://hold-url/.well-known/did.json
4545-func (s *Server) SetDefaultHoldDID(did string) {
4646- s.defaultHoldDID = did
4747-}
4848-4941// SetRefresher sets the refresher for invalidating session cache
5042func (s *Server) SetRefresher(refresher *Refresher) {
5143 s.refresher = refresher
···5648 s.uiSessionStore = store
5749}
58505959-// SetDatabase sets the database for user management
6060-func (s *Server) SetDatabase(db *sql.DB) {
6161- s.db = db
5151+// SetPostAuthCallback sets the callback to be invoked after successful OAuth authentication
5252+// This allows AppView to inject business logic without coupling the OAuth package
5353+func (s *Server) SetPostAuthCallback(callback PostAuthCallback) {
5454+ s.postAuthCallback = callback
6255}
63566457// ServeAuthorize handles GET /auth/oauth/authorize
···140133 handle = did // Fallback to DID if resolution fails
141134 }
142135143143- // Fetch user's Bluesky profile (including avatar) and store in database
144144- if s.db != nil {
145145- s.fetchAndStoreAvatar(r.Context(), did, sessionID, handle, sessionData.HostURL)
136136+ // Call post-auth callback for AppView business logic (profile, avatar, etc.)
137137+ if s.postAuthCallback != nil {
138138+ if err := s.postAuthCallback(r.Context(), did, handle, sessionData.HostURL, sessionID); err != nil {
139139+ // Log error but don't fail OAuth flow - business logic is non-critical
140140+ fmt.Printf("WARNING [oauth/server]: Post-auth callback failed for DID=%s: %v\n", did, err)
141141+ }
146142 }
147143148144 // Check if this is a UI login (has oauth_return_to cookie)
···239235 if err := tmpl.Execute(w, data); err != nil {
240236 http.Error(w, "failed to render template", http.StatusInternalServerError)
241237 }
242242-}
243243-244244-// fetchAndStoreAvatar fetches the user's Bluesky profile and stores avatar in database
245245-func (s *Server) fetchAndStoreAvatar(ctx context.Context, did, sessionID, handle, pdsEndpoint string) {
246246- fmt.Printf("DEBUG [oauth/server]: Fetching avatar for DID=%s from PDS=%s\n", did, pdsEndpoint)
247247-248248- // Parse DID for session resume
249249- didParsed, err := syntax.ParseDID(did)
250250- if err != nil {
251251- fmt.Printf("WARNING [oauth/server]: Failed to parse DID %s: %v\n", did, err)
252252- return
253253- }
254254-255255- // Resume OAuth session to get authenticated client
256256- session, err := s.app.ResumeSession(ctx, didParsed, sessionID)
257257- if err != nil {
258258- fmt.Printf("WARNING [oauth/server]: Failed to resume session for DID=%s: %v\n", did, err)
259259- // Fallback: update user without avatar
260260- _ = db.UpsertUser(s.db, &db.User{
261261- DID: did,
262262- Handle: handle,
263263- PDSEndpoint: pdsEndpoint,
264264- Avatar: "",
265265- LastSeen: time.Now(),
266266- })
267267- return
268268- }
269269-270270- // Create authenticated atproto client using the indigo session's API client
271271- client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
272272-273273- // Ensure sailor profile exists (creates with default hold if configured, or empty profile if not)
274274- fmt.Printf("DEBUG [oauth/server]: Ensuring profile exists for %s (defaultHold=%s)\n", did, s.defaultHoldDID)
275275- if err := atproto.EnsureProfile(ctx, client, s.defaultHoldDID); err != nil {
276276- fmt.Printf("WARNING [oauth/server]: Failed to ensure profile for %s: %v\n", did, err)
277277- // Continue anyway - profile creation is not critical for avatar fetch
278278- } else {
279279- fmt.Printf("DEBUG [oauth/server]: Profile ensured for %s\n", did)
280280- }
281281-282282- // Fetch user's profile record from PDS (contains blob references)
283283- profileRecord, err := client.GetProfileRecord(ctx, did)
284284- if err != nil {
285285- fmt.Printf("WARNING [oauth/server]: Failed to fetch profile record for DID=%s: %v\n", did, err)
286286- // Still update user without avatar
287287- _ = db.UpsertUser(s.db, &db.User{
288288- DID: did,
289289- Handle: handle,
290290- PDSEndpoint: pdsEndpoint,
291291- Avatar: "",
292292- LastSeen: time.Now(),
293293- })
294294- return
295295- }
296296-297297- // Construct avatar URL from blob CID using imgs.blue CDN
298298- var avatarURL string
299299- if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
300300- avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link)
301301- fmt.Printf("DEBUG [oauth/server]: Constructed avatar URL: %s\n", avatarURL)
302302- }
303303-304304- // Store user with avatar in database
305305- err = db.UpsertUser(s.db, &db.User{
306306- DID: did,
307307- Handle: handle,
308308- PDSEndpoint: pdsEndpoint,
309309- Avatar: avatarURL,
310310- LastSeen: time.Now(),
311311- })
312312- if err != nil {
313313- fmt.Printf("WARNING [oauth/server]: Failed to store user in database: %v\n", err)
314314- return
315315- }
316316-317317- fmt.Printf("DEBUG [oauth/server]: Stored user with avatar for DID=%s\n", did)
318318-319319- // Handle profile migration and crew registration
320320- s.migrateProfileAndRegisterCrew(ctx, client, did, session)
321321-}
322322-323323-// migrateProfileAndRegisterCrew handles URL→DID migration and crew registration
324324-func (s *Server) migrateProfileAndRegisterCrew(ctx context.Context, client *atproto.Client, did string, session *indigooauth.ClientSession) {
325325- // Get user's sailor profile
326326- profile, err := atproto.GetProfile(ctx, client)
327327- if err != nil {
328328- fmt.Printf("WARNING [oauth/server]: Failed to get profile for %s: %v\n", did, err)
329329- return
330330- }
331331-332332- if profile == nil || profile.DefaultHold == "" {
333333- // No profile or no default hold configured
334334- return
335335- }
336336-337337- // Check if defaultHold is a URL (needs migration)
338338- var holdDID string
339339- if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
340340- fmt.Printf("DEBUG [oauth/server]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold)
341341-342342- // Resolve URL to DID
343343- holdDID = atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
344344-345345- // Update profile with DID
346346- profile.DefaultHold = holdDID
347347- if err := atproto.UpdateProfile(ctx, client, profile); err != nil {
348348- fmt.Printf("WARNING [oauth/server]: Failed to update profile with hold DID for %s: %v\n", did, err)
349349- // Continue anyway - crew registration might still work
350350- } else {
351351- fmt.Printf("DEBUG [oauth/server]: Updated profile with hold DID: %s\n", holdDID)
352352- }
353353- } else {
354354- // Already a DID
355355- holdDID = profile.DefaultHold
356356- }
357357-358358- // TODO: Request crew membership at the hold
359359- // This requires understanding how to make authenticated HTTP requests with indigo's ClientSession
360360- // For now, crew registration will happen on first push when appview validates access
361361- fmt.Printf("DEBUG [oauth/server]: Skipping crew registration for now - will happen on first push. Hold DID: %s\n", holdDID)
362362- _ = session // TODO: use session for crew registration
363238}
364239365240// HTML templates
+38-31
pkg/auth/token/handler.go
···11package token
2233import (
44+ "context"
45 "encoding/json"
56 "fmt"
67 "net/http"
···1112 "github.com/bluesky-social/indigo/atproto/syntax"
12131314 "atcr.io/pkg/appview/db"
1414- mainAtproto "atcr.io/pkg/atproto"
1515 "atcr.io/pkg/auth"
1616)
17171818+// PostAuthCallback is called after successful Basic Auth authentication.
1919+// Parameters: ctx, did, handle, pdsEndpoint, accessToken
2020+// This allows AppView to perform business logic (profile creation, etc.)
2121+// without coupling the token package to AppView-specific dependencies.
2222+type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error
2323+1824// Handler handles /auth/token requests
1925type Handler struct {
2020- issuer *Issuer
2121- validator *auth.SessionValidator
2222- deviceStore *db.DeviceStore // For validating device secrets
2323- defaultHoldDID string
2626+ issuer *Issuer
2727+ validator *auth.SessionValidator
2828+ deviceStore *db.DeviceStore // For validating device secrets
2929+ postAuthCallback PostAuthCallback
2430}
25312632// NewHandler creates a new token handler
2727-// defaultHoldDID should be in format "did:web:hold01.atcr.io"
2828-// To find a hold's DID, visit: https://hold-url/.well-known/did.json
2929-func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldDID string) *Handler {
3333+func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler {
3034 return &Handler{
3131- issuer: issuer,
3232- validator: auth.NewSessionValidator(),
3333- deviceStore: deviceStore,
3434- defaultHoldDID: defaultHoldDID,
3535+ issuer: issuer,
3636+ validator: auth.NewSessionValidator(),
3737+ deviceStore: deviceStore,
3538 }
3939+}
4040+4141+// SetPostAuthCallback sets the callback to be invoked after successful Basic Auth authentication
4242+// This allows AppView to inject business logic without coupling the token package
4343+func (h *Handler) SetPostAuthCallback(callback PostAuthCallback) {
4444+ h.postAuthCallback = callback
3645}
37463847// TokenResponse represents the response from /auth/token
···142151 auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
143152 fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did)
144153145145- // Ensure user profile exists (creates with default hold if needed)
146146- // Resolve PDS endpoint for profile management
147147- directory := identity.DefaultDirectory()
148148- atID, err := syntax.ParseAtIdentifier(username)
149149- if err == nil {
150150- ident, err := directory.Lookup(r.Context(), *atID)
151151- if err != nil {
152152- // Log error but don't fail auth - profile management is not critical
153153- fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
154154- } else {
155155- pdsEndpoint := ident.PDSEndpoint()
156156- if pdsEndpoint != "" {
157157- // Create ATProto client with validated token
158158- atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
159159-160160- // Ensure profile exists (will create with default hold if not exists and default is configured)
161161- if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldDID); err != nil {
162162- // Log error but don't fail auth - profile management is not critical
163163- fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
154154+ // Call post-auth callback for AppView business logic (profile management, etc.)
155155+ if h.postAuthCallback != nil {
156156+ // Resolve PDS endpoint for callback
157157+ directory := identity.DefaultDirectory()
158158+ atID, err := syntax.ParseAtIdentifier(username)
159159+ if err == nil {
160160+ ident, err := directory.Lookup(r.Context(), *atID)
161161+ if err != nil {
162162+ // Log error but don't fail auth - profile management is not critical
163163+ fmt.Printf("WARNING: failed to resolve PDS for callback: %v\n", err)
164164+ } else {
165165+ pdsEndpoint := ident.PDSEndpoint()
166166+ if pdsEndpoint != "" {
167167+ if err := h.postAuthCallback(r.Context(), did, handle, pdsEndpoint, accessToken); err != nil {
168168+ // Log error but don't fail auth - business logic is non-critical
169169+ fmt.Printf("WARNING: post-auth callback failed for DID=%s: %v\n", did, err)
170170+ }
164171 }
165172 }
166173 }
+111
pkg/auth/token/servicetoken.go
···11+package token
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "io"
88+ "net/http"
99+ "net/url"
1010+ "time"
1111+1212+ "atcr.io/pkg/atproto"
1313+ "atcr.io/pkg/auth/oauth"
1414+)
1515+1616+// GetOrFetchServiceToken gets a service token for hold authentication.
1717+// Checks cache first, then fetches from PDS with OAuth/DPoP if needed.
1818+// This is the canonical implementation used by both middleware and crew registration.
1919+func GetOrFetchServiceToken(
2020+ ctx context.Context,
2121+ refresher *oauth.Refresher,
2222+ did, holdDID, pdsEndpoint string,
2323+) (string, error) {
2424+ if refresher == nil {
2525+ return "", fmt.Errorf("refresher is nil (OAuth session required for service tokens)")
2626+ }
2727+2828+ // Check cache first to avoid unnecessary PDS calls on every request
2929+ cachedToken, expiresAt := GetServiceToken(did, holdDID)
3030+3131+ // Use cached token if it exists and has > 10s remaining
3232+ if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
3333+ fmt.Printf("DEBUG [atproto/servicetoken]: Using cached service token for DID=%s (expires in %v)\n",
3434+ did, time.Until(expiresAt).Round(time.Second))
3535+ return cachedToken, nil
3636+ }
3737+3838+ // Cache miss or expiring soon - validate OAuth and get new service token
3939+ if cachedToken == "" {
4040+ fmt.Printf("DEBUG [atproto/servicetoken]: Cache miss, fetching service token for DID=%s\n", did)
4141+ } else {
4242+ fmt.Printf("DEBUG [atproto/servicetoken]: Token expiring soon, proactively renewing for DID=%s\n", did)
4343+ }
4444+4545+ session, err := refresher.GetSession(ctx, did)
4646+ if err != nil {
4747+ // OAuth session unavailable - invalidate and fail
4848+ refresher.InvalidateSession(did)
4949+ InvalidateServiceToken(did, holdDID)
5050+ return "", fmt.Errorf("failed to get OAuth session: %w", err)
5151+ }
5252+5353+ // Call com.atproto.server.getServiceAuth on the user's PDS
5454+ // Request 5-minute expiry (PDS may grant less)
5555+ // exp must be absolute Unix timestamp, not relative duration
5656+ // Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID
5757+ expiryTime := time.Now().Unix() + 300 // 5 minutes from now
5858+ serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
5959+ pdsEndpoint,
6060+ atproto.ServerGetServiceAuth,
6161+ url.QueryEscape(holdDID),
6262+ url.QueryEscape("com.atproto.repo.getRecord"),
6363+ expiryTime,
6464+ )
6565+6666+ req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
6767+ if err != nil {
6868+ return "", fmt.Errorf("failed to create service auth request: %w", err)
6969+ }
7070+7171+ // Use OAuth session to authenticate to PDS (with DPoP)
7272+ resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
7373+ if err != nil {
7474+ // Invalidate session on auth errors (may indicate corrupted session or expired tokens)
7575+ refresher.InvalidateSession(did)
7676+ InvalidateServiceToken(did, holdDID)
7777+ return "", fmt.Errorf("OAuth validation failed: %w", err)
7878+ }
7979+ defer resp.Body.Close()
8080+8181+ if resp.StatusCode != http.StatusOK {
8282+ // Invalidate session on auth failures
8383+ bodyBytes, _ := io.ReadAll(resp.Body)
8484+ refresher.InvalidateSession(did)
8585+ InvalidateServiceToken(did, holdDID)
8686+ return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
8787+ }
8888+8989+ // Parse response to get service token
9090+ var result struct {
9191+ Token string `json:"token"`
9292+ }
9393+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
9494+ return "", fmt.Errorf("failed to decode service auth response: %w", err)
9595+ }
9696+9797+ if result.Token == "" {
9898+ return "", fmt.Errorf("empty token in service auth response")
9999+ }
100100+101101+ serviceToken := result.Token
102102+103103+ // Cache the token (parses JWT to extract actual expiry)
104104+ if err := SetServiceToken(did, holdDID, serviceToken); err != nil {
105105+ fmt.Printf("WARN [atproto/servicetoken]: Failed to cache service token: %v\n", err)
106106+ // Non-fatal - we have the token, just won't be cached
107107+ }
108108+109109+ fmt.Printf("DEBUG [atproto/servicetoken]: OAuth validation succeeded for DID=%s\n", did)
110110+ return serviceToken, nil
111111+}
+1-1
pkg/hold/config.go
···40404141 // EnableBlueskyPosts controls whether to create Bluesky posts for manifest uploads (from env: HOLD_BLUESKY_POSTS_ENABLED)
4242 // If true, creates posts when users push images
4343- // Can be overridden per-hold via captain record's enableManifestPosts field
4343+ // Synced to captain record's enableBlueskyPosts field on startup
4444 EnableBlueskyPosts bool `yaml:"enable_bluesky_posts"`
4545}
4646
+9-3
pkg/hold/oci/xrpc.go
···244244 }
245245246246 // Check if manifest posts are enabled
247247- // Controlled by HOLD_BLUESKY_POSTS_ENABLED environment variable
248248- // TODO: Override with captain record enableManifestPosts field if set
249249- postsEnabled := h.enableBlueskyPosts
247247+ // Read from captain record (which is synced with HOLD_BLUESKY_POSTS_ENABLED env var)
248248+ postsEnabled := false
249249+ _, captain, err := h.pds.GetCaptainRecord(ctx)
250250+ if err == nil {
251251+ postsEnabled = captain.EnableBlueskyPosts
252252+ } else {
253253+ // Fallback to env var if captain record doesn't exist (shouldn't happen in normal operation)
254254+ postsEnabled = h.enableBlueskyPosts
255255+ }
250256251257 // Create layer records for each blob
252258 layersCreated := 0