···11package main
2233import (
44+ "crypto/rand"
55+ "encoding/hex"
46 "fmt"
57 "net/url"
68 "os"
···7173 addr := getEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
7274 debugAddr := getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001")
73757676+ // HTTP secret - only needed for multipart uploads in distribution's storage driver
7777+ // Since AppView is stateless and routes all storage through middleware, this isn't
7878+ // actually used, but we generate a random secret for defense in depth
7979+ httpSecret := os.Getenv("REGISTRY_HTTP_SECRET")
8080+ if httpSecret == "" {
8181+ // Generate a random 32-byte secret
8282+ randomBytes := make([]byte, 32)
8383+ if _, err := rand.Read(randomBytes); err != nil {
8484+ return configuration.HTTP{}, fmt.Errorf("failed to generate random secret: %w", err)
8585+ }
8686+ httpSecret = hex.EncodeToString(randomBytes)
8787+ }
8888+7489 return configuration.HTTP{
7575- Addr: addr,
9090+ Addr: addr,
9191+ Secret: httpSecret,
7692 Headers: map[string][]string{
7793 "X-Content-Type-Options": {"nosniff"},
7894 },
···108124109125// buildMiddlewareConfig creates middleware configuration
110126func buildMiddlewareConfig(defaultHold string) map[string][]configuration.Middleware {
127127+ // Check test mode
128128+ testMode := os.Getenv("TEST_MODE") == "true"
129129+111130 return map[string][]configuration.Middleware{
112131 "registry": {
113132 {
114133 Name: "atproto-resolver",
115134 Options: configuration.Parameters{
116135 "default_storage_endpoint": defaultHold,
136136+ "test_mode": testMode,
117137 },
118138 },
119139 },
+41-14
cmd/appview/serve.go
···2020 "github.com/spf13/cobra"
21212222 "atcr.io/pkg/appview/middleware"
2323+ "atcr.io/pkg/atproto"
2424+ "atcr.io/pkg/auth"
2325 "atcr.io/pkg/auth/oauth"
2426 "atcr.io/pkg/auth/token"
2527···147149 metricsDB := db.NewMetricsDB(uiDatabase)
148150 middleware.SetGlobalDatabase(metricsDB)
149151152152+ // 6.6. Create RemoteHoldAuthorizer for hold authorization with caching
153153+ holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase)
154154+ middleware.SetGlobalAuthorizer(holdAuthorizer)
155155+ fmt.Println("Hold authorizer initialized with database caching")
156156+157157+ // 6.7. Extract default hold DID for OAuth server and backfill worker
158158+ // This is used to create sailor profiles on first login and cache captain records
159159+ // Expected format: "did:web:hold01.atcr.io"
160160+ // To find a hold's DID, visit: https://hold01.atcr.io/.well-known/did.json
161161+ // The extraction function normalizes URLs to DIDs for consistency
162162+ defaultHoldDID := extractDefaultHoldDID(config)
163163+150164 // 7. Initialize UI routes with OAuth app, refresher, and device store
151151- uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore)
165165+ uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID)
152166153167 // 8. Create OAuth server
154168 oauthServer := oauth.NewServer(oauthApp)
···161175 // Connect database for user avatar management
162176 oauthServer.SetDatabase(uiDatabase)
163177164164- // 8.5. Extract default hold endpoint and set it on OAuth server
178178+ // 8.5. Set default hold DID on OAuth server (extracted earlier)
165179 // This is used to create sailor profiles on first login
166166- defaultHoldEndpoint := extractDefaultHoldEndpoint(config)
167167- if defaultHoldEndpoint != "" {
168168- oauthServer.SetDefaultHoldEndpoint(defaultHoldEndpoint)
169169- fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldEndpoint)
180180+ if defaultHoldDID != "" {
181181+ oauthServer.SetDefaultHoldDID(defaultHoldDID)
182182+ fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldDID)
170183 }
171184172185 // 9. Initialize auth keys and create token issuer
···227240 // Mount auth endpoints if enabled
228241 if issuer != nil {
229242 // Basic Auth token endpoint (supports device secrets and app passwords)
230230- // Reuse defaultHoldEndpoint extracted earlier
231231- tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldEndpoint)
243243+ // Reuse defaultHoldDID extracted earlier
244244+ tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldDID)
232245 tokenHandler.RegisterRoutes(mux)
233246234247 // Device authorization endpoints (public)
···351364 return defaultValue
352365}
353366354354-// extractDefaultHoldEndpoint extracts the default hold endpoint from middleware config
355355-func extractDefaultHoldEndpoint(config *configuration.Configuration) string {
367367+// extractDefaultHoldDID extracts the default hold DID from middleware config
368368+// Returns a DID (e.g., "did:web:hold01.atcr.io") for consistency
369369+// Accepts both DIDs and URLs in config for backward compatibility
370370+// To find a hold's DID, visit: https://hold-url/.well-known/did.json
371371+func extractDefaultHoldDID(config *configuration.Configuration) string {
356372 // Navigate through: middleware.registry[].options.default_storage_endpoint
357373 registryMiddleware, ok := config.Middleware["registry"]
358374 if !ok {
···369385 // Extract options - options is configuration.Parameters which is map[string]any
370386 if mw.Options != nil {
371387 if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok {
372372- return endpoint
388388+ // Normalize to DID (handles both URLs and DIDs)
389389+ // This ensures we store DIDs consistently
390390+ return atproto.ResolveHoldDIDFromURL(endpoint)
373391 }
374392 }
375393 }
···447465// initializeUIRoutes initializes the web UI routes
448466// database: read-write connection for auth and writes
449467// readOnlyDB: read-only connection for public queries (search, user pages, etc.)
450450-func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore) (*template.Template, *mux.Router) {
468468+// defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io")
469469+func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string) (*template.Template, *mux.Router) {
451470 // Check if UI is enabled
452471 uiEnabled := os.Getenv("ATCR_UI_ENABLED")
453472 if uiEnabled == "false" {
···647666 relayEndpoint = "https://relay1.us-east.bsky.network"
648667 }
649668650650- backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint)
669669+ // Check test mode
670670+ testMode := os.Getenv("TEST_MODE") == "true"
671671+672672+ backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode)
651673 if err != nil {
652674 fmt.Printf("Warning: Failed to create backfill worker: %v\n", err)
653675 } else {
654654- // Run initial backfill
676676+ // Run initial backfill with startup delay for Docker compose
655677 go func() {
678678+ // Wait for hold service to be ready (Docker startup race condition)
679679+ startupDelay := 5 * time.Second
680680+ fmt.Printf("Backfill: Waiting %s for services to be ready...\n", startupDelay)
681681+ time.Sleep(startupDelay)
682682+656683 fmt.Printf("Backfill: Starting sync-based backfill from %s...\n", relayEndpoint)
657684 if err := backfillWorker.Start(context.Background()); err != nil {
658685 fmt.Printf("Backfill: Finished with error: %v\n", err)
+15-11
cmd/hold/main.go
···2323 log.Fatalf("Failed to load config: %v", err)
2424 }
25252626- // Create hold service
2727- service, err := hold.NewHoldService(cfg)
2828- if err != nil {
2929- log.Fatalf("Failed to create hold service: %v", err)
3030- }
3131-3226 // Initialize embedded PDS if database path is configured
2727+ // This must happen before creating HoldService since service needs PDS for authorization
3328 var holdPDS *pds.HoldPDS
3429 var xrpcHandler *pds.XRPCHandler
3530 if cfg.Database.Path != "" {
···4944 log.Fatalf("Failed to bootstrap PDS: %v", err)
5045 }
51465252- // Create blob store adapter
5353- blobStore := pds.NewHoldServiceBlobStore(service, holdDID)
4747+ log.Printf("Embedded PDS initialized successfully")
4848+ } else {
4949+ log.Fatalf("Database path is required for embedded PDS authorization")
5050+ }
5151+5252+ // Create hold service with PDS
5353+ service, err := hold.NewHoldService(cfg, holdPDS)
5454+ if err != nil {
5555+ log.Fatalf("Failed to create hold service: %v", err)
5656+ }
54575555- // Create XRPC handler
5858+ // Create blob store adapter and XRPC handler
5959+ if holdPDS != nil {
6060+ holdDID := holdPDS.DID()
6161+ blobStore := hold.NewHoldServiceBlobStore(service, holdDID)
5662 xrpcHandler = pds.NewXRPCHandler(holdPDS, cfg.Server.PublicURL, blobStore)
5757-5858- log.Printf("Embedded PDS initialized successfully")
5963 }
60646165 // Setup HTTP routes
+3-1
docker-compose.yml
···1313 environment:
1414 # Server configuration
1515 ATCR_HTTP_ADDR: :5000
1616- ATCR_DEFAULT_HOLD: http://atcr-hold:8080
1616+ ATCR_DEFAULT_HOLD: http://172.28.0.3:8080
1717 # UI configuration
1818 ATCR_UI_ENABLED: true
1919 ATCR_BACKFILL_ENABLED: true
2020+ # Test mode - fallback to default hold when user's hold is unreachable
2121+ TEST_MODE: true
2022 # Logging
2123 ATCR_LOG_LEVEL: info
2224 volumes:
+4-4
docs/SAILOR.md
···3131 4. Create Profile Management
32323333 File: pkg/atproto/profile.go (new file)
3434- - EnsureProfile(ctx, client, defaultHoldEndpoint) function
3434+ - EnsureProfile(ctx, client, defaultHoldDID) function
3535 - Logic: check if profile exists, create with default if not
36363737 5. Update Auth Handlers
···3939 Files: pkg/auth/exchange/handler.go and pkg/auth/token/service.go
4040 - Call EnsureProfile() after token validation
4141 - Use authenticated client (has write access to user's PDS)
4242- - Pass AppView's default_hold_endpoint config
4242+ - Pass AppView's default_hold_did config (format: "did:web:hold01.atcr.io")
43434444 6. Update Hold Resolution
4545···8989 5. Updated /auth/exchange handler to manage profile
90909191 ⏳ In Progress:
9292- - Need to update /auth/token handler similarly (add defaultHoldEndpoint parameter and profile management)
9393- - Fix compilation error in extractDefaultHoldEndpoint() - should use configuration.Middleware type not any
9292+ - Need to update /auth/token handler similarly (add defaultHoldDID parameter and profile management)
9393+ - Fix compilation error in extractDefaultHoldDID() - should use configuration.Middleware type not any
94949595 🔜 Remaining:
9696 - Update findStorageEndpoint() for new priority logic (check profile → own hold → default)
+7-7
gen/main.go
···77// Usage:
88// go run gen/main.go
99//
1010-// This creates pkg/hold/pds/cbor_gen.go which should be committed to git.
1111-// Only re-run when you modify types in pkg/hold/pds/types.go
1010+// This creates pkg/atproto/cbor_gen.go which should be committed to git.
1111+// Only re-run when you modify types in pkg/atproto/types.go
12121313import (
1414 "fmt"
···16161717 cbg "github.com/whyrusleeping/cbor-gen"
18181919- "atcr.io/pkg/hold/pds"
1919+ "atcr.io/pkg/atproto"
2020)
21212222func main() {
2323 // Generate map-style encoders for CrewRecord and CaptainRecord
2424- if err := cbg.WriteMapEncodersToFile("pkg/hold/pds/cbor_gen.go", "pds",
2525- pds.CrewRecord{},
2626- pds.CaptainRecord{},
2424+ if err := cbg.WriteMapEncodersToFile("pkg/atproto/cbor_gen.go", "atproto",
2525+ atproto.CrewRecord{},
2626+ atproto.CaptainRecord{},
2727 ); err != nil {
2828 fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
2929 os.Exit(1)
3030 }
31313232- fmt.Println("Generated CBOR encoders in pkg/hold/pds/cbor_gen.go")
3232+ fmt.Println("Generated CBOR encoders in pkg/atproto/cbor_gen.go")
3333}
···11+description: Add crew cache tables for authorization with exponential backoff
22+query: |
33+ CREATE TABLE IF NOT EXISTS hold_crew_approvals (
44+ hold_did TEXT NOT NULL,
55+ user_did TEXT NOT NULL,
66+ approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
77+ expires_at TIMESTAMP NOT NULL,
88+ PRIMARY KEY(hold_did, user_did)
99+ );
1010+ CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at);
1111+1212+ CREATE TABLE IF NOT EXISTS hold_crew_denials (
1313+ hold_did TEXT NOT NULL,
1414+ user_did TEXT NOT NULL,
1515+ denial_count INTEGER NOT NULL DEFAULT 1,
1616+ next_retry_at TIMESTAMP NOT NULL,
1717+ last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
1818+ PRIMARY KEY(hold_did, user_did)
1919+ );
2020+ CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
+19
pkg/appview/db/schema.go
···179179 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
180180);
181181CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
182182+183183+CREATE TABLE IF NOT EXISTS hold_crew_approvals (
184184+ hold_did TEXT NOT NULL,
185185+ user_did TEXT NOT NULL,
186186+ approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
187187+ expires_at TIMESTAMP NOT NULL,
188188+ PRIMARY KEY(hold_did, user_did)
189189+);
190190+CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at);
191191+192192+CREATE TABLE IF NOT EXISTS hold_crew_denials (
193193+ hold_did TEXT NOT NULL,
194194+ user_did TEXT NOT NULL,
195195+ denial_count INTEGER NOT NULL DEFAULT 1,
196196+ next_retry_at TIMESTAMP NOT NULL,
197197+ last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
198198+ PRIMARY KEY(hold_did, user_did)
199199+);
200200+CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
182201`
183202184203// InitDB initializes the SQLite database with the schema
+181-10
pkg/appview/jetstream/backfill.go
···17171818// BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data
1919type BackfillWorker struct {
2020- db *sql.DB
2121- client *atproto.Client
2222- directory identity.Directory
2020+ db *sql.DB
2121+ client *atproto.Client
2222+ directory identity.Directory
2323+ defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io")
2424+ testMode bool // If true, suppress warnings for external holds
2325}
24262527// BackfillState tracks backfill progress
···3436}
35373638// NewBackfillWorker creates a backfill worker using sync API
3737-func NewBackfillWorker(database *sql.DB, relayEndpoint string) (*BackfillWorker, error) {
3939+// defaultHoldDID should be in format "did:web:hold01.atcr.io"
4040+// To find a hold's DID, visit: https://hold-url/.well-known/did.json
4141+func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool) (*BackfillWorker, error) {
3842 // Create client for relay - used only for listReposByCollection
3943 client := atproto.NewClient(relayEndpoint, "", "")
40444145 return &BackfillWorker{
4242- db: database,
4343- client: client, // This points to the relay
4444- directory: identity.DefaultDirectory(),
4646+ db: database,
4747+ client: client, // This points to the relay
4848+ directory: identity.DefaultDirectory(),
4949+ defaultHoldDID: defaultHoldDID,
5050+ testMode: testMode,
4551 }, nil
4652}
4753···4955func (b *BackfillWorker) Start(ctx context.Context) error {
5056 fmt.Println("Backfill: Starting sync-based backfill...")
51575858+ // First, query and cache the default hold's captain record
5959+ if b.defaultHoldDID != "" {
6060+ fmt.Printf("Backfill: Querying default hold captain record: %s\n", b.defaultHoldDID)
6161+ if err := b.queryCaptainRecord(ctx, b.defaultHoldDID); err != nil {
6262+ fmt.Printf("WARNING: Failed to query default hold captain record: %v\n", err)
6363+ // Don't fail the whole backfill - just warn
6464+ }
6565+ }
6666+5267 collections := []string{
5353- atproto.ManifestCollection, // io.atcr.manifest
5454- atproto.TagCollection, // io.atcr.tag
5555- atproto.StarCollection, // io.atcr.sailor.star
6868+ atproto.ManifestCollection, // io.atcr.manifest
6969+ atproto.TagCollection, // io.atcr.tag
7070+ atproto.StarCollection, // io.atcr.sailor.star
7171+ atproto.SailorProfileCollection, // io.atcr.sailor.profile
5672 }
57735874 for _, collection := range collections {
···267283 return b.processTagRecord(did, record)
268284 case atproto.StarCollection:
269285 return b.processStarRecord(did, record)
286286+ case atproto.SailorProfileCollection:
287287+ return b.processSailorProfileRecord(ctx, did, record)
270288 default:
271289 return fmt.Errorf("unsupported collection: %s", collection)
272290 }
···362380 // The subject contains the owner DID and repository
363381 // Star count will be calculated on demand from the stars table
364382 return db.UpsertStar(b.db, did, starRecord.Subject.DID, starRecord.Subject.Repository, starRecord.CreatedAt)
383383+}
384384+385385+// processSailorProfileRecord processes a sailor profile record
386386+// Extracts defaultHold and queries the hold's captain record to cache it
387387+func (b *BackfillWorker) processSailorProfileRecord(ctx context.Context, did string, record *atproto.Record) error {
388388+ var profileRecord atproto.SailorProfileRecord
389389+ if err := json.Unmarshal(record.Value, &profileRecord); err != nil {
390390+ return fmt.Errorf("failed to unmarshal sailor profile: %w", err)
391391+ }
392392+393393+ // Skip if no default hold set
394394+ if profileRecord.DefaultHold == "" {
395395+ return nil
396396+ }
397397+398398+ // Convert hold URL/DID to canonical DID
399399+ holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold)
400400+ if holdDID == "" {
401401+ fmt.Printf("WARNING [backfill]: Invalid hold reference in profile for %s: %s\n", did, profileRecord.DefaultHold)
402402+ return nil
403403+ }
404404+405405+ // Query and cache the captain record
406406+ if err := b.queryCaptainRecord(ctx, holdDID); err != nil {
407407+ // In test mode, only warn about default hold (local hold)
408408+ // External/production holds may not have captain records yet (dev ahead of prod)
409409+ if b.testMode && holdDID != b.defaultHoldDID {
410410+ // Suppress warning for external holds in test mode
411411+ return nil
412412+ }
413413+ fmt.Printf("WARNING [backfill]: Failed to query captain record for hold %s: %v\n", holdDID, err)
414414+ // Don't fail the whole backfill - just skip this hold
415415+ return nil
416416+ }
417417+418418+ return nil
419419+}
420420+421421+// queryCaptainRecord queries a hold's captain record and caches it in the database
422422+func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string) error {
423423+ // Check if we already have it cached (skip if recently updated)
424424+ existing, err := db.GetCaptainRecord(b.db, holdDID)
425425+ if err == nil && existing != nil {
426426+ // If cached within last hour, skip refresh
427427+ if time.Since(existing.UpdatedAt) < 1*time.Hour {
428428+ return nil
429429+ }
430430+ }
431431+432432+ // Resolve hold DID to URL
433433+ // For did:web, we need to fetch .well-known/did.json
434434+ holdURL, err := resolveHoldDIDToURL(ctx, holdDID)
435435+ if err != nil {
436436+ return fmt.Errorf("failed to resolve hold DID to URL: %w", err)
437437+ }
438438+439439+ // Create client for hold's PDS
440440+ holdClient := atproto.NewClient(holdURL, holdDID, "")
441441+442442+ // Query captain record with retries (for Docker startup timing)
443443+ var record *atproto.Record
444444+ maxRetries := 3
445445+ for attempt := 1; attempt <= maxRetries; attempt++ {
446446+ record, err = holdClient.GetRecord(ctx, "io.atcr.hold.captain", "self")
447447+ if err == nil {
448448+ break
449449+ }
450450+451451+ // Retry on connection errors (hold service might still be starting)
452452+ if attempt < maxRetries && strings.Contains(err.Error(), "connection refused") {
453453+ fmt.Printf("Backfill: Hold not ready (attempt %d/%d), retrying in 2s...\n", attempt, maxRetries)
454454+ time.Sleep(2 * time.Second)
455455+ continue
456456+ }
457457+458458+ return fmt.Errorf("failed to get captain record: %w", err)
459459+ }
460460+461461+ // Parse captain record from the record's Value field
462462+ var captainRecord struct {
463463+ Owner string `json:"owner"`
464464+ Public bool `json:"public"`
465465+ AllowAllCrew bool `json:"allowAllCrew"`
466466+ DeployedAt string `json:"deployedAt"`
467467+ Region string `json:"region"`
468468+ Provider string `json:"provider"`
469469+ }
470470+471471+ if err := json.Unmarshal(record.Value, &captainRecord); err != nil {
472472+ return fmt.Errorf("failed to parse captain record: %w", err)
473473+ }
474474+475475+ // Cache in database
476476+ dbRecord := &db.HoldCaptainRecord{
477477+ HoldDID: holdDID,
478478+ OwnerDID: captainRecord.Owner,
479479+ Public: captainRecord.Public,
480480+ AllowAllCrew: captainRecord.AllowAllCrew,
481481+ DeployedAt: captainRecord.DeployedAt,
482482+ Region: captainRecord.Region,
483483+ Provider: captainRecord.Provider,
484484+ UpdatedAt: time.Now(),
485485+ }
486486+487487+ if err := db.UpsertCaptainRecord(b.db, dbRecord); err != nil {
488488+ return fmt.Errorf("failed to cache captain record: %w", err)
489489+ }
490490+491491+ fmt.Printf("Backfill: Cached captain record for hold %s (owner: %s)\n", holdDID, captainRecord.Owner)
492492+ return nil
493493+}
494494+495495+// resolveHoldDIDToURL resolves a hold DID to its service endpoint URL
496496+// Fetches the DID document and returns both the canonical DID and service endpoint
497497+func resolveHoldDIDToURL(ctx context.Context, inputDID string) (string, error) {
498498+ // For did:web, construct the .well-known URL
499499+ if !strings.HasPrefix(inputDID, "did:web:") {
500500+ return "", fmt.Errorf("only did:web is supported, got: %s", inputDID)
501501+ }
502502+503503+ // Extract hostname from did:web:hostname[:port]
504504+ hostname := strings.TrimPrefix(inputDID, "did:web:")
505505+506506+ // Try HTTP first (for local Docker), then HTTPS
507507+ var serviceEndpoint string
508508+ for _, scheme := range []string{"http", "https"} {
509509+ testURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, hostname)
510510+511511+ // Fetch DID document (use NewClient to initialize httpClient)
512512+ client := atproto.NewClient("", "", "")
513513+ didDoc, err := client.FetchDIDDocument(ctx, testURL)
514514+ if err == nil && didDoc != nil {
515515+ // Extract service endpoint from DID document
516516+ for _, service := range didDoc.Service {
517517+ if service.Type == "AtprotoPersonalDataServer" || service.Type == "AtcrHoldService" {
518518+ serviceEndpoint = service.ServiceEndpoint
519519+ break
520520+ }
521521+ }
522522+523523+ if serviceEndpoint != "" {
524524+ fmt.Printf("DEBUG [backfill]: Resolved %s → canonical DID: %s, endpoint: %s\n",
525525+ inputDID, didDoc.ID, serviceEndpoint)
526526+ return serviceEndpoint, nil
527527+ }
528528+ }
529529+ }
530530+531531+ // Fallback: assume the hold service is at the root of the hostname
532532+ // Try HTTP first for local development
533533+ url := fmt.Sprintf("http://%s", hostname)
534534+ fmt.Printf("WARNING [backfill]: Failed to fetch DID document for %s, using fallback URL: %s\n", inputDID, url)
535535+ return url, nil
365536}
366537367538// ensureUser resolves and upserts a user by DID
+51-5
pkg/appview/middleware/registry.go
···2929 IncrementPushCount(did, repository string) error
3030}
31313232+// Global authorizer instance (set by main.go for hold authorization)
3333+var globalAuthorizer auth.HoldAuthorizer
3434+3235// SetGlobalRefresher sets the global OAuth refresher instance
3336func SetGlobalRefresher(refresher *oauth.Refresher) {
3437 globalRefresher = refresher
···4245 globalDatabase = database
4346}
44474848+// SetGlobalAuthorizer sets the global authorizer instance for hold access control
4949+func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) {
5050+ globalAuthorizer = authorizer
5151+}
5252+4553func init() {
4654 // Register the name resolution middleware
4755 registrymw.Register("atproto-resolver", initATProtoResolver)
···5260 distribution.Namespace
5361 directory identity.Directory
5462 defaultStorageEndpoint string
6363+ testMode bool // If true, fallback to default hold when user's hold is unreachable
5564 repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
5665}
5766···6170 directory := identity.DefaultDirectory()
62716372 // Get default storage endpoint from config (optional)
7373+ // Normalize to DID format for consistency
6474 defaultStorageEndpoint := ""
6575 if endpoint, ok := options["default_storage_endpoint"].(string); ok {
6666- defaultStorageEndpoint = endpoint
7676+ // Convert URL to DID if needed (or pass through if already a DID)
7777+ defaultStorageEndpoint = atproto.ResolveHoldDIDFromURL(endpoint)
7878+ }
7979+8080+ // Check test mode from options (passed via env var)
8181+ testMode := false
8282+ if tm, ok := options["test_mode"].(bool); ok {
8383+ testMode = tm
6784 }
68856986 return &NamespaceResolver{
7087 Namespace: ns,
7188 directory: directory,
7289 defaultStorageEndpoint: defaultStorageEndpoint,
9090+ testMode: testMode,
7391 }, nil
7492}
7593···177195178196 // Create routing repository - routes manifests to ATProto, blobs to hold service
179197 // The registry is stateless - no local storage is used
180180- // Pass storage endpoint and DID as parameters (can't use context as it gets lost)
181181- routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase)
198198+ // Pass storage endpoint, DID, and authorizer as parameters (can't use context as it gets lost)
199199+ routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase, globalAuthorizer)
182200183201 // Cache the repository
184202 nr.repositories.Store(cacheKey, routingRepo)
···206224// 1. User's sailor profile defaultHold (if set)
207225// 2. User's own hold record (io.atcr.hold)
208226// 3. AppView's default hold endpoint
209209-// Returns the storage endpoint URL, or empty string if none configured
227227+// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
228228+// Note: Despite returning a DID, this is used as the "storage endpoint" throughout the code
210229func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEndpoint string) string {
211230 // Create ATProto client (without auth - reading public records)
212231 client := atproto.NewClient(pdsEndpoint, did, "")
···219238 }
220239221240 if profile != nil && profile.DefaultHold != "" {
222222- // Profile exists with defaultHold set - use it
241241+ // Profile exists with defaultHold set
242242+ // In test mode, verify it's reachable before using it
243243+ if nr.testMode {
244244+ if nr.isHoldReachable(ctx, profile.DefaultHold) {
245245+ return profile.DefaultHold
246246+ }
247247+ fmt.Printf("DEBUG [registry/middleware/testmode]: User's defaultHold %s unreachable, falling back to default\n", profile.DefaultHold)
248248+ return nr.defaultStorageEndpoint
249249+ }
223250 return profile.DefaultHold
224251 }
225252···247274 // 3. No profile defaultHold and no own hold records - use AppView default
248275 return nr.defaultStorageEndpoint
249276}
277277+278278+// isHoldReachable checks if a hold service is reachable
279279+// Used in test mode to fallback to default hold when user's hold is unavailable
280280+func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string) bool {
281281+ // Try to fetch the DID document
282282+ hostname := strings.TrimPrefix(holdDID, "did:web:")
283283+284284+ // Try HTTP first (local), then HTTPS
285285+ for _, scheme := range []string{"http", "https"} {
286286+ testURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, hostname)
287287+ client := atproto.NewClient("", "", "")
288288+ _, err := client.FetchDIDDocument(ctx, testURL)
289289+ if err == nil {
290290+ return true
291291+ }
292292+ }
293293+294294+ return false
295295+}
+80-2
pkg/appview/storage/proxy_blob_store.go
···1010 "sync"
1111 "time"
12121313+ "atcr.io/pkg/atproto"
1414+ "atcr.io/pkg/auth"
1315 "github.com/distribution/distribution/v3"
1416 "github.com/opencontainers/go-digest"
1517)
···3436 did string
3537 database DatabaseMetrics
3638 repository string
3939+ authorizer auth.HoldAuthorizer
4040+ holdDID string
3741}
38423943// NewProxyBlobStore creates a new proxy blob store
4040-func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, repository string) *ProxyBlobStore {
4141- fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, did=%s, repo=%s\n", storageEndpoint, did, repository)
4444+func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore {
4545+ // Convert storage endpoint URL to did:web DID for authorization
4646+ holdDID := atproto.ResolveHoldDIDFromURL(storageEndpoint)
4747+ fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, holdDID=%s, userDID=%s, repo=%s\n",
4848+ storageEndpoint, holdDID, did, repository)
4949+4250 return &ProxyBlobStore{
4351 storageEndpoint: storageEndpoint,
4452 httpClient: &http.Client{
···5462 did: did,
5563 database: database,
5664 repository: repository,
6565+ authorizer: authorizer,
6666+ holdDID: holdDID,
5767 }
5868}
59697070+// checkReadAccess verifies the user has read access to the hold
7171+func (p *ProxyBlobStore) checkReadAccess(ctx context.Context) error {
7272+ if p.authorizer == nil {
7373+ // No authorizer configured - allow access (backward compatibility)
7474+ return nil
7575+ }
7676+7777+ hasAccess, err := p.authorizer.CheckReadAccess(ctx, p.holdDID, p.did)
7878+ if err != nil {
7979+ return fmt.Errorf("authorization check failed: %w", err)
8080+ }
8181+8282+ if !hasAccess {
8383+ return distribution.ErrBlobUnknown // Return same error as missing blob for security
8484+ }
8585+8686+ return nil
8787+}
8888+8989+// checkWriteAccess verifies the user has write access to the hold
9090+func (p *ProxyBlobStore) checkWriteAccess(ctx context.Context) error {
9191+ if p.authorizer == nil {
9292+ // No authorizer configured - allow access (backward compatibility)
9393+ return nil
9494+ }
9595+9696+ hasAccess, err := p.authorizer.CheckWriteAccess(ctx, p.holdDID, p.did)
9797+ if err != nil {
9898+ return fmt.Errorf("authorization check failed: %w", err)
9999+ }
100100+101101+ if !hasAccess {
102102+ return fmt.Errorf("write access denied to hold %s", p.holdDID)
103103+ }
104104+105105+ return nil
106106+}
107107+60108// Stat returns the descriptor for a blob
61109func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) {
110110+ // Check read access
111111+ if err := p.checkReadAccess(ctx); err != nil {
112112+ return distribution.Descriptor{}, err
113113+ }
114114+62115 // Get presigned HEAD URL
63116 url, err := p.getHeadURL(ctx, dgst)
64117 if err != nil {
···9614997150// Get retrieves a blob
98151func (p *ProxyBlobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, error) {
152152+ // Check read access
153153+ if err := p.checkReadAccess(ctx); err != nil {
154154+ return nil, err
155155+ }
156156+99157 url, err := p.getDownloadURL(ctx, dgst)
100158 if err != nil {
101159 return nil, err
···117175118176// Open returns a reader for a blob
119177func (p *ProxyBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) {
178178+ // Check read access
179179+ if err := p.checkReadAccess(ctx); err != nil {
180180+ return nil, err
181181+ }
182182+120183 url, err := p.getDownloadURL(ctx, dgst)
121184 if err != nil {
122185 return nil, err
···141204142205// Put stores a blob
143206func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []byte) (distribution.Descriptor, error) {
207207+ // Check write access
208208+ if err := p.checkWriteAccess(ctx); err != nil {
209209+ return distribution.Descriptor{}, err
210210+ }
211211+144212 // Calculate digest
145213 dgst := digest.FromBytes(content)
146214···189257190258// ServeBlob serves a blob via HTTP redirect
191259func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
260260+ // Check read access
261261+ if err := p.checkReadAccess(ctx); err != nil {
262262+ return err
263263+ }
264264+192265 // For HEAD requests, redirect to presigned HEAD URL
193266 if r.Method == http.MethodHead {
194267 url, err := p.getHeadURL(ctx, dgst)
···214287215288// Create returns a blob writer for uploading using multipart upload
216289func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
290290+ // Check write access
291291+ if err := p.checkWriteAccess(ctx); err != nil {
292292+ return nil, err
293293+ }
294294+217295 // Parse options
218296 var opts distribution.CreateOptions
219297 for _, option := range options {
+6-2
pkg/appview/storage/routing_repository.go
···66 "time"
7788 "atcr.io/pkg/atproto"
99+ "atcr.io/pkg/auth"
910 "github.com/distribution/distribution/v3"
1011)
1112···2627 manifestStore *atproto.ManifestStore // Cached manifest store instance
2728 blobStore *ProxyBlobStore // Cached blob store instance
2829 database DatabaseMetrics // Database for metrics tracking
3030+ authorizer auth.HoldAuthorizer // Authorization for hold access
2931}
30323133// NewRoutingRepository creates a new routing repository
···3638 storageEndpoint string,
3739 did string,
3840 database DatabaseMetrics,
4141+ authorizer auth.HoldAuthorizer,
3942) *RoutingRepository {
4043 return &RoutingRepository{
4144 Repository: baseRepo,
···4447 storageEndpoint: storageEndpoint,
4548 did: did,
4649 database: database,
5050+ authorizer: authorizer,
4751 }
4852}
4953···105109 panic("storage endpoint not set in RoutingRepository - ensure default_storage_endpoint is configured in middleware")
106110 }
107111108108- // Create and cache proxy blob store
109109- r.blobStore = NewProxyBlobStore(holdEndpoint, r.did, r.database, r.repositoryName)
112112+ // Create and cache proxy blob store with authorization
113113+ r.blobStore = NewProxyBlobStore(holdEndpoint, r.did, r.database, r.repositoryName, r.authorizer)
110114 return r.blobStore
111115}
112116
+36
pkg/atproto/client.go
···625625func BlobCDNURL(didOrHandle, cid string) string {
626626 return fmt.Sprintf("https://imgs.blue/%s/%s", didOrHandle, cid)
627627}
628628+629629+// DIDDocument represents a did:web document
630630+type DIDDocument struct {
631631+ Context []string `json:"@context"`
632632+ ID string `json:"id"`
633633+ Service []struct {
634634+ ID string `json:"id"`
635635+ Type string `json:"type"`
636636+ ServiceEndpoint string `json:"serviceEndpoint"`
637637+ } `json:"service"`
638638+}
639639+640640+// FetchDIDDocument fetches and parses a DID document from a URL
641641+func (c *Client) FetchDIDDocument(ctx context.Context, didDocURL string) (*DIDDocument, error) {
642642+ req, err := http.NewRequestWithContext(ctx, "GET", didDocURL, nil)
643643+ if err != nil {
644644+ return nil, err
645645+ }
646646+647647+ resp, err := c.httpClient.Do(req)
648648+ if err != nil {
649649+ return nil, fmt.Errorf("failed to fetch DID document: %w", err)
650650+ }
651651+ defer resp.Body.Close()
652652+653653+ if resp.StatusCode != http.StatusOK {
654654+ return nil, fmt.Errorf("fetch DID document failed with status %d", resp.StatusCode)
655655+ }
656656+657657+ var didDoc DIDDocument
658658+ if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
659659+ return nil, fmt.Errorf("failed to decode DID document: %w", err)
660660+ }
661661+662662+ return &didDoc, nil
663663+}
+44-1
pkg/atproto/lexicon.go
···11package atproto
2233+//go:generate go run github.com/whyrusleeping/cbor-gen --map-encoding CrewRecord CaptainRecord
44+35import (
46 "encoding/base64"
57 "encoding/json"
···1921 // HoldCollection is the collection name for storage holds (BYOS)
2022 HoldCollection = "io.atcr.hold"
21232222- // HoldCrewCollection is the collection name for hold crew (membership)
2424+ // HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model
2525+ // Stored in owner's PDS for BYOS holds
2326 HoldCrewCollection = "io.atcr.hold.crew"
2727+2828+ // CaptainCollection is the collection name for captain records (hold ownership) - EMBEDDED PDS model
2929+ // Stored in hold's embedded PDS (singleton record at rkey "self")
3030+ CaptainCollection = "io.atcr.hold.captain"
3131+3232+ // CrewCollection is the collection name for crew records (access control) - EMBEDDED PDS model
3333+ // Stored in hold's embedded PDS (one record per member)
3434+ // Note: Uses same collection name as HoldCrewCollection but stored in different PDS (hold's PDS vs owner's PDS)
3535+ CrewCollection = "io.atcr.hold.crew"
24362537 // SailorProfileCollection is the collection name for user profiles
2638 SailorProfileCollection = "io.atcr.sailor.profile"
···371383 // did:web uses hostname directly (port included if non-standard)
372384 return "did:web:" + hostname
373385}
386386+387387+// =============================================================================
388388+// Embedded PDS Types (Hold Service)
389389+// =============================================================================
390390+391391+// CaptainRecord represents the hold's ownership and metadata
392392+// Collection: io.atcr.hold.captain (singleton record at rkey "self")
393393+// Stored in the hold's embedded PDS to identify the hold owner and settings
394394+// Uses CBOR encoding for efficient storage in hold's carstore
395395+type CaptainRecord struct {
396396+ Type string `json:"$type" cborgen:"$type"`
397397+ Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
398398+ Public bool `json:"public" cborgen:"public"` // Public read access
399399+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
400400+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
401401+ Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
402402+ Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
403403+}
404404+405405+// CrewRecord represents a crew member in the hold
406406+// Collection: io.atcr.hold.crew (one record per member)
407407+// Stored in the hold's embedded PDS for access control
408408+// Uses CBOR encoding for efficient storage in hold's carstore
409409+// Note: Same collection name as HoldCrewRecord but stored in hold's PDS (not owner's PDS)
410410+type CrewRecord struct {
411411+ Type string `json:"$type" cborgen:"$type"`
412412+ Member string `json:"member" cborgen:"member"`
413413+ Role string `json:"role" cborgen:"role"`
414414+ Permissions []string `json:"permissions" cborgen:"permissions"`
415415+ AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
416416+}
+35-5
pkg/atproto/profile.go
···12121313// EnsureProfile checks if a user's profile exists and creates it if needed
1414// This should be called during authentication (OAuth exchange or token service)
1515-// If defaultHoldEndpoint is provided, creates profile with that default (or empty if not provided)
1616-func EnsureProfile(ctx context.Context, client *Client, defaultHoldEndpoint string) error {
1515+// If defaultHoldDID is provided, creates profile with that default (or empty if not provided)
1616+// Expected format: "did:web:hold01.atcr.io"
1717+// Normalizes URLs to DIDs for consistency (for backward compatibility)
1818+func EnsureProfile(ctx context.Context, client *Client, defaultHoldDID string) error {
1719 // Check if profile already exists
1820 profile, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
1921 if err == nil && profile != nil {
···2123 return nil
2224 }
23252626+ // Normalize to DID if it's a URL (or pass through if already a DID)
2727+ // This ensures we store DIDs consistently in new profiles
2828+ normalizedDID := ""
2929+ if defaultHoldDID != "" {
3030+ normalizedDID = ResolveHoldDIDFromURL(defaultHoldDID)
3131+ }
3232+2433 // Profile doesn't exist - create it
2525- // defaultHoldEndpoint can be empty string (user will need to configure it later)
2626- newProfile := NewSailorProfileRecord(defaultHoldEndpoint)
3434+ newProfile := NewSailorProfileRecord(normalizedDID)
27352836 _, err = client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, newProfile)
2937 if err != nil {
3038 return fmt.Errorf("failed to create sailor profile: %w", err)
3139 }
32403333- fmt.Printf("DEBUG [profile]: Created sailor profile with defaultHold=%s\n", defaultHoldEndpoint)
4141+ fmt.Printf("DEBUG [profile]: Created sailor profile with defaultHold=%s\n", normalizedDID)
3442 return nil
3543}
36443745// GetProfile retrieves the user's profile from their PDS
3846// Returns nil if profile doesn't exist
4747+// Automatically migrates old URL-based defaultHold values to DIDs
3948func GetProfile(ctx context.Context, client *Client) (*SailorProfileRecord, error) {
4049 record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
4150 if err != nil {
···5261 return nil, fmt.Errorf("failed to parse profile: %w", err)
5362 }
54636464+ // Migrate old URL-based defaultHold to DID format
6565+ // This ensures backward compatibility with profiles created before DID migration
6666+ if profile.DefaultHold != "" && !isDID(profile.DefaultHold) {
6767+ // Convert URL to DID transparently
6868+ profile.DefaultHold = ResolveHoldDIDFromURL(profile.DefaultHold)
6969+ fmt.Printf("DEBUG [profile]: Migrated defaultHold URL to DID: %s\n", profile.DefaultHold)
7070+ }
7171+5572 return &profile, nil
5673}
57747575+// isDID checks if a string is a DID (starts with "did:")
7676+func isDID(s string) bool {
7777+ return len(s) > 4 && s[:4] == "did:"
7878+}
7979+5880// UpdateProfile updates the user's profile
8181+// Normalizes defaultHold to DID format before saving
5982func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error {
8383+ // Normalize defaultHold to DID if it's a URL
8484+ // This ensures we always store DIDs, even if user provides a URL
8585+ if profile.DefaultHold != "" && !isDID(profile.DefaultHold) {
8686+ profile.DefaultHold = ResolveHoldDIDFromURL(profile.DefaultHold)
8787+ fmt.Printf("DEBUG [profile]: Normalized defaultHold to DID: %s\n", profile.DefaultHold)
8888+ }
8989+6090 _, err := client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, profile)
6191 if err != nil {
6292 return fmt.Errorf("failed to update profile: %w", err)
+77
pkg/auth/hold_authorizer.go
···11+package auth
22+33+import (
44+ "context"
55+ "fmt"
66+77+ "atcr.io/pkg/atproto"
88+)
99+1010+// HoldAuthorizer checks if a DID has read/write access to a hold
1111+// Implementations can query local PDS (hold service) or remote XRPC (appview)
1212+type HoldAuthorizer interface {
1313+ // CheckReadAccess checks if userDID can read from holdDID
1414+ // Returns: (allowed bool, error)
1515+ CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error)
1616+1717+ // CheckWriteAccess checks if userDID can write to holdDID
1818+ // Returns: (allowed bool, error)
1919+ CheckWriteAccess(ctx context.Context, holdDID, userDID string) (bool, error)
2020+2121+ // GetCaptainRecord retrieves the captain record for a hold
2222+ // Used to check public flag and allowAllCrew settings
2323+ GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error)
2424+2525+ // IsCrewMember checks if userDID is a crew member of holdDID
2626+ IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error)
2727+}
2828+2929+// CheckReadAccessWithCaptain implements the standard read authorization logic
3030+// This is shared across all HoldAuthorizer implementations
3131+// Read access rules:
3232+// - Public hold: allow anyone (even anonymous)
3333+// - Private hold: require authentication (any authenticated user)
3434+func CheckReadAccessWithCaptain(captain *atproto.CaptainRecord, userDID string) bool {
3535+ if captain.Public {
3636+ // Public hold - allow anyone (even anonymous)
3737+ return true
3838+ }
3939+4040+ // Private hold - require authentication
4141+ // Any authenticated user with a DID can read
4242+ if userDID == "" {
4343+ // Anonymous user trying to access private hold
4444+ return false
4545+ }
4646+4747+ // For MVP: assume DID presence means they have sailor.profile
4848+ // Future: could query PDS to verify sailor.profile exists
4949+ return true
5050+}
5151+5252+// CheckWriteAccessWithCaptain implements the standard write authorization logic
5353+// This is shared across all HoldAuthorizer implementations
5454+// Write access rules:
5555+// - Must be authenticated
5656+// - Must be hold owner OR crew member
5757+func CheckWriteAccessWithCaptain(captain *atproto.CaptainRecord, userDID string, isCrew bool) bool {
5858+ if userDID == "" {
5959+ // Anonymous writes not allowed
6060+ return false
6161+ }
6262+6363+ // Check if DID is the hold owner
6464+ if userDID == captain.Owner {
6565+ // Owner always has write access
6666+ return true
6767+ }
6868+6969+ // Check if DID is a crew member
7070+ return isCrew
7171+}
7272+7373+// ErrHoldNotFound is returned when a hold's captain record cannot be found
7474+var ErrHoldNotFound = fmt.Errorf("hold not found")
7575+7676+// ErrUnauthorized is returned when access is denied
7777+var ErrUnauthorized = fmt.Errorf("unauthorized")
+101
pkg/auth/hold_local.go
···11+package auth
22+33+import (
44+ "context"
55+ "fmt"
66+77+ "atcr.io/pkg/atproto"
88+ "atcr.io/pkg/hold/pds"
99+)
1010+1111+// LocalHoldAuthorizer queries the hold's own embedded PDS directly
1212+// Used by hold service to authorize access to its own storage
1313+type LocalHoldAuthorizer struct {
1414+ pds *pds.HoldPDS
1515+}
1616+1717+// NewLocalHoldAuthorizer creates a new local authorizer for hold service
1818+func NewLocalHoldAuthorizer(holdPDS *pds.HoldPDS) HoldAuthorizer {
1919+ return &LocalHoldAuthorizer{
2020+ pds: holdPDS,
2121+ }
2222+}
2323+2424+// NewLocalHoldAuthorizerFromInterface creates a new local authorizer from an any
2525+// This is used to avoid import cycles - caller must pass a *pds.HoldPDS
2626+func NewLocalHoldAuthorizerFromInterface(holdPDS any) HoldAuthorizer {
2727+ // Type assert to *pds.HoldPDS
2828+ if pdsTyped, ok := holdPDS.(*pds.HoldPDS); ok {
2929+ return &LocalHoldAuthorizer{
3030+ pds: pdsTyped,
3131+ }
3232+ }
3333+ // Return nil if type assertion fails - caller should check
3434+ return nil
3535+}
3636+3737+// GetCaptainRecord retrieves the captain record from the hold's PDS
3838+func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
3939+ // Verify that the requested holdDID matches this hold
4040+ if holdDID != a.pds.DID() {
4141+ return nil, fmt.Errorf("holdDID mismatch: requested %s, this hold is %s", holdDID, a.pds.DID())
4242+ }
4343+4444+ // Query the PDS for captain record
4545+ _, pdsCaptain, err := a.pds.GetCaptainRecord(ctx)
4646+ if err != nil {
4747+ return nil, fmt.Errorf("failed to get captain record: %w", err)
4848+ }
4949+5050+ // The PDS returns *atproto.CaptainRecord directly now (after we update pds to use atproto types)
5151+ return pdsCaptain, nil
5252+}
5353+5454+// IsCrewMember checks if userDID is a crew member
5555+func (a *LocalHoldAuthorizer) IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) {
5656+ // Verify that the requested holdDID matches this hold
5757+ if holdDID != a.pds.DID() {
5858+ return false, fmt.Errorf("holdDID mismatch: requested %s, this hold is %s", holdDID, a.pds.DID())
5959+ }
6060+6161+ // Query the PDS for crew list
6262+ crewList, err := a.pds.ListCrewMembers(ctx)
6363+ if err != nil {
6464+ return false, fmt.Errorf("failed to list crew members: %w", err)
6565+ }
6666+6767+ // Check if userDID is in the crew list
6868+ for _, member := range crewList {
6969+ if member.Record.Member == userDID {
7070+ // TODO: Check expiration if set
7171+ return true, nil
7272+ }
7373+ }
7474+7575+ return false, nil
7676+}
7777+7878+// CheckReadAccess implements read authorization using shared logic
7979+func (a *LocalHoldAuthorizer) CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
8080+ captain, err := a.GetCaptainRecord(ctx, holdDID)
8181+ if err != nil {
8282+ return false, err
8383+ }
8484+8585+ return CheckReadAccessWithCaptain(captain, userDID), nil
8686+}
8787+8888+// CheckWriteAccess implements write authorization using shared logic
8989+func (a *LocalHoldAuthorizer) CheckWriteAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
9090+ captain, err := a.GetCaptainRecord(ctx, holdDID)
9191+ if err != nil {
9292+ return false, err
9393+ }
9494+9595+ isCrew, err := a.IsCrewMember(ctx, holdDID, userDID)
9696+ if err != nil {
9797+ return false, err
9898+ }
9999+100100+ return CheckWriteAccessWithCaptain(captain, userDID, isCrew), nil
101101+}
+559
pkg/auth/hold_remote.go
···11+package auth
22+33+import (
44+ "context"
55+ "database/sql"
66+ "encoding/json"
77+ "fmt"
88+ "io"
99+ "net/http"
1010+ "net/url"
1111+ "strings"
1212+ "sync"
1313+ "time"
1414+1515+ "atcr.io/pkg/atproto"
1616+)
1717+1818+// RemoteHoldAuthorizer queries a hold's PDS via XRPC endpoints
1919+// Used by AppView to authorize access to remote holds
2020+// Implements caching for captain records to reduce XRPC calls
2121+type RemoteHoldAuthorizer struct {
2222+ db *sql.DB
2323+ httpClient *http.Client
2424+ cacheTTL time.Duration // TTL for captain record cache
2525+ recentDenials sync.Map // In-memory cache for first denials (10s backoff)
2626+ stopCleanup chan struct{} // Signal to stop cleanup goroutine
2727+}
2828+2929+// denialEntry stores timestamp for in-memory first denials
3030+type denialEntry struct {
3131+ timestamp time.Time
3232+}
3333+3434+// NewRemoteHoldAuthorizer creates a new remote authorizer for AppView
3535+func NewRemoteHoldAuthorizer(db *sql.DB) HoldAuthorizer {
3636+ a := &RemoteHoldAuthorizer{
3737+ db: db,
3838+ httpClient: &http.Client{
3939+ Timeout: 10 * time.Second,
4040+ },
4141+ cacheTTL: 1 * time.Hour, // 1 hour cache TTL
4242+ stopCleanup: make(chan struct{}),
4343+ }
4444+4545+ // Start cleanup goroutine for in-memory denials
4646+ go a.cleanupRecentDenials()
4747+4848+ return a
4949+}
5050+5151+// cleanupRecentDenials runs every 10s to remove expired first-denial entries
5252+func (a *RemoteHoldAuthorizer) cleanupRecentDenials() {
5353+ ticker := time.NewTicker(10 * time.Second)
5454+ defer ticker.Stop()
5555+5656+ for {
5757+ select {
5858+ case <-ticker.C:
5959+ now := time.Now()
6060+ a.recentDenials.Range(func(key, value any) bool {
6161+ entry := value.(denialEntry)
6262+ // Remove entries older than 15 seconds (10s backoff + 5s grace)
6363+ if now.Sub(entry.timestamp) > 15*time.Second {
6464+ a.recentDenials.Delete(key)
6565+ }
6666+ return true
6767+ })
6868+ case <-a.stopCleanup:
6969+ return
7070+ }
7171+ }
7272+}
7373+7474+// GetCaptainRecord retrieves a captain record with caching
7575+// 1. Check database cache
7676+// 2. If cache miss or expired, query hold's XRPC endpoint
7777+// 3. Update cache
7878+func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
7979+ // Try cache first
8080+ if a.db != nil {
8181+ cached, err := a.getCachedCaptainRecord(holdDID)
8282+ if err == nil && cached != nil {
8383+ // Cache hit - check if still valid
8484+ if time.Since(cached.UpdatedAt) < a.cacheTTL {
8585+ return cached.CaptainRecord, nil
8686+ }
8787+ // Cache expired - continue to fetch fresh data
8888+ }
8989+ }
9090+9191+ // Cache miss or expired - query XRPC endpoint
9292+ record, err := a.fetchCaptainRecordFromXRPC(ctx, holdDID)
9393+ if err != nil {
9494+ return nil, err
9595+ }
9696+9797+ // Update cache
9898+ if a.db != nil {
9999+ if err := a.setCachedCaptainRecord(holdDID, record); err != nil {
100100+ // Log error but don't fail - caching is best-effort
101101+ fmt.Printf("WARNING: Failed to cache captain record: %v\n", err)
102102+ }
103103+ }
104104+105105+ return record, nil
106106+}
107107+108108+// captainRecordWithMeta includes UpdatedAt for cache management
109109+type captainRecordWithMeta struct {
110110+ *atproto.CaptainRecord
111111+ UpdatedAt time.Time
112112+}
113113+114114+// getCachedCaptainRecord retrieves a captain record from database cache
115115+func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainRecordWithMeta, error) {
116116+ query := `
117117+ SELECT owner_did, public, allow_all_crew, deployed_at, region, provider, updated_at
118118+ FROM hold_captain_records
119119+ WHERE hold_did = ?
120120+ `
121121+122122+ var record atproto.CaptainRecord
123123+ var deployedAt, region, provider sql.NullString
124124+ var updatedAt time.Time
125125+126126+ err := a.db.QueryRow(query, holdDID).Scan(
127127+ &record.Owner,
128128+ &record.Public,
129129+ &record.AllowAllCrew,
130130+ &deployedAt,
131131+ ®ion,
132132+ &provider,
133133+ &updatedAt,
134134+ )
135135+136136+ if err == sql.ErrNoRows {
137137+ return nil, nil // Cache miss
138138+ }
139139+140140+ if err != nil {
141141+ return nil, fmt.Errorf("cache query failed: %w", err)
142142+ }
143143+144144+ // Handle nullable fields
145145+ if deployedAt.Valid {
146146+ record.DeployedAt = deployedAt.String
147147+ }
148148+ if region.Valid {
149149+ record.Region = region.String
150150+ }
151151+ if provider.Valid {
152152+ record.Provider = provider.String
153153+ }
154154+155155+ return &captainRecordWithMeta{
156156+ CaptainRecord: &record,
157157+ UpdatedAt: updatedAt,
158158+ }, nil
159159+}
160160+161161+// setCachedCaptainRecord stores a captain record in database cache
162162+func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.CaptainRecord) error {
163163+ query := `
164164+ INSERT INTO hold_captain_records (
165165+ hold_did, owner_did, public, allow_all_crew,
166166+ deployed_at, region, provider, updated_at
167167+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
168168+ ON CONFLICT(hold_did) DO UPDATE SET
169169+ owner_did = excluded.owner_did,
170170+ public = excluded.public,
171171+ allow_all_crew = excluded.allow_all_crew,
172172+ deployed_at = excluded.deployed_at,
173173+ region = excluded.region,
174174+ provider = excluded.provider,
175175+ updated_at = excluded.updated_at
176176+ `
177177+178178+ _, err := a.db.Exec(query,
179179+ holdDID,
180180+ record.Owner,
181181+ record.Public,
182182+ record.AllowAllCrew,
183183+ nullString(record.DeployedAt),
184184+ nullString(record.Region),
185185+ nullString(record.Provider),
186186+ time.Now(),
187187+ )
188188+189189+ return err
190190+}
191191+192192+// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
193193+func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
194194+ // Resolve DID to URL
195195+ holdURL, err := resolveDIDToURL(holdDID)
196196+ if err != nil {
197197+ return nil, fmt.Errorf("failed to resolve hold DID: %w", err)
198198+ }
199199+200200+ // Build XRPC request URL
201201+ // GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection=io.atcr.hold.captain&rkey=self
202202+ xrpcURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=self",
203203+ holdURL, url.QueryEscape(holdDID), url.QueryEscape(atproto.CaptainCollection))
204204+205205+ req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
206206+ if err != nil {
207207+ return nil, err
208208+ }
209209+210210+ resp, err := a.httpClient.Do(req)
211211+ if err != nil {
212212+ return nil, fmt.Errorf("XRPC request failed: %w", err)
213213+ }
214214+ defer resp.Body.Close()
215215+216216+ if resp.StatusCode != http.StatusOK {
217217+ body, _ := io.ReadAll(resp.Body)
218218+ return nil, fmt.Errorf("XRPC request failed: status %d: %s", resp.StatusCode, string(body))
219219+ }
220220+221221+ // Parse response
222222+ var xrpcResp struct {
223223+ URI string `json:"uri"`
224224+ CID string `json:"cid"`
225225+ Value struct {
226226+ Type string `json:"$type"`
227227+ Owner string `json:"owner"`
228228+ Public bool `json:"public"`
229229+ AllowAllCrew bool `json:"allowAllCrew"`
230230+ DeployedAt string `json:"deployedAt"`
231231+ Region string `json:"region,omitempty"`
232232+ Provider string `json:"provider,omitempty"`
233233+ } `json:"value"`
234234+ }
235235+236236+ if err := json.NewDecoder(resp.Body).Decode(&xrpcResp); err != nil {
237237+ return nil, fmt.Errorf("failed to decode XRPC response: %w", err)
238238+ }
239239+240240+ // Convert to our type
241241+ record := &atproto.CaptainRecord{
242242+ Type: atproto.CaptainCollection,
243243+ Owner: xrpcResp.Value.Owner,
244244+ Public: xrpcResp.Value.Public,
245245+ AllowAllCrew: xrpcResp.Value.AllowAllCrew,
246246+ DeployedAt: xrpcResp.Value.DeployedAt,
247247+ Region: xrpcResp.Value.Region,
248248+ Provider: xrpcResp.Value.Provider,
249249+ }
250250+251251+ return record, nil
252252+}
253253+254254+// IsCrewMember checks if userDID is a crew member with caching
255255+// 1. Check approval cache (15min TTL)
256256+// 2. Check denial cache with exponential backoff
257257+// 3. If cache miss, query XRPC endpoint and update cache
258258+func (a *RemoteHoldAuthorizer) IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) {
259259+ // Skip caching if no database
260260+ if a.db == nil {
261261+ return a.isCrewMemberNoCache(ctx, holdDID, userDID)
262262+ }
263263+264264+ // Check approval cache first (15min TTL)
265265+ if approved, err := a.getCachedApproval(holdDID, userDID); err == nil && approved {
266266+ return true, nil
267267+ }
268268+269269+ // Check denial cache with backoff
270270+ if blocked, err := a.isBlockedByDenialBackoff(holdDID, userDID); err == nil && blocked {
271271+ // Still in backoff period - don't query again
272272+ return false, nil
273273+ }
274274+275275+ // Cache miss or expired - query XRPC endpoint
276276+ isCrew, err := a.isCrewMemberNoCache(ctx, holdDID, userDID)
277277+ if err != nil {
278278+ return false, err
279279+ }
280280+281281+ // Update cache based on result
282282+ if isCrew {
283283+ // Cache approval for 15 minutes
284284+ _ = a.cacheApproval(holdDID, userDID, 15*time.Minute)
285285+ } else {
286286+ // Cache denial with exponential backoff
287287+ _ = a.cacheDenial(holdDID, userDID)
288288+ }
289289+290290+ return isCrew, nil
291291+}
292292+293293+// isCrewMemberNoCache queries XRPC without caching (internal helper)
294294+func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) {
295295+ // Resolve DID to URL
296296+ holdURL, err := resolveDIDToURL(holdDID)
297297+ if err != nil {
298298+ return false, fmt.Errorf("failed to resolve hold DID: %w", err)
299299+ }
300300+301301+ // Build XRPC request URL
302302+ // GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection=io.atcr.hold.crew
303303+ xrpcURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s",
304304+ holdURL, url.QueryEscape(holdDID), url.QueryEscape(atproto.CrewCollection))
305305+306306+ req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
307307+ if err != nil {
308308+ return false, err
309309+ }
310310+311311+ resp, err := a.httpClient.Do(req)
312312+ if err != nil {
313313+ return false, fmt.Errorf("XRPC request failed: %w", err)
314314+ }
315315+ defer resp.Body.Close()
316316+317317+ if resp.StatusCode != http.StatusOK {
318318+ body, _ := io.ReadAll(resp.Body)
319319+ return false, fmt.Errorf("XRPC request failed: status %d: %s", resp.StatusCode, string(body))
320320+ }
321321+322322+ // Parse response
323323+ var xrpcResp struct {
324324+ Records []struct {
325325+ URI string `json:"uri"`
326326+ CID string `json:"cid"`
327327+ Value struct {
328328+ Type string `json:"$type"`
329329+ Member string `json:"member"`
330330+ Role string `json:"role"`
331331+ Permissions []string `json:"permissions"`
332332+ AddedAt string `json:"addedAt"`
333333+ } `json:"value"`
334334+ } `json:"records"`
335335+ }
336336+337337+ if err := json.NewDecoder(resp.Body).Decode(&xrpcResp); err != nil {
338338+ return false, fmt.Errorf("failed to decode XRPC response: %w", err)
339339+ }
340340+341341+ // Check if userDID is in the crew list
342342+ for _, record := range xrpcResp.Records {
343343+ if record.Value.Member == userDID {
344344+ // TODO: Check expiration if set
345345+ return true, nil
346346+ }
347347+ }
348348+349349+ return false, nil
350350+}
351351+352352+// CheckReadAccess implements read authorization using shared logic
353353+func (a *RemoteHoldAuthorizer) CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
354354+ captain, err := a.GetCaptainRecord(ctx, holdDID)
355355+ if err != nil {
356356+ return false, err
357357+ }
358358+359359+ return CheckReadAccessWithCaptain(captain, userDID), nil
360360+}
361361+362362+// CheckWriteAccess implements write authorization using shared logic
363363+func (a *RemoteHoldAuthorizer) CheckWriteAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
364364+ captain, err := a.GetCaptainRecord(ctx, holdDID)
365365+ if err != nil {
366366+ return false, err
367367+ }
368368+369369+ isCrew, err := a.IsCrewMember(ctx, holdDID, userDID)
370370+ if err != nil {
371371+ return false, err
372372+ }
373373+374374+ return CheckWriteAccessWithCaptain(captain, userDID, isCrew), nil
375375+}
376376+377377+// resolveDIDToURL converts a did:web DID to an HTTPS URL
378378+// Example: did:web:hold01.atcr.io → https://hold01.atcr.io
379379+func resolveDIDToURL(did string) (string, error) {
380380+ // Handle did:web format
381381+ if !strings.HasPrefix(did, "did:web:") {
382382+ return "", fmt.Errorf("only did:web is supported, got: %s", did)
383383+ }
384384+385385+ // Extract hostname from did:web:hostname
386386+ hostname := strings.TrimPrefix(did, "did:web:")
387387+388388+ // Convert to HTTPS URL
389389+ return "https://" + hostname, nil
390390+}
391391+392392+// nullString converts a string to sql.NullString
393393+func nullString(s string) sql.NullString {
394394+ if s == "" {
395395+ return sql.NullString{Valid: false}
396396+ }
397397+ return sql.NullString{String: s, Valid: true}
398398+}
399399+400400+// getCachedApproval checks if user has a cached crew approval
401401+func (a *RemoteHoldAuthorizer) getCachedApproval(holdDID, userDID string) (bool, error) {
402402+ query := `
403403+ SELECT expires_at
404404+ FROM hold_crew_approvals
405405+ WHERE hold_did = ? AND user_did = ?
406406+ `
407407+408408+ var expiresAt time.Time
409409+ err := a.db.QueryRow(query, holdDID, userDID).Scan(&expiresAt)
410410+411411+ if err == sql.ErrNoRows {
412412+ return false, nil // Cache miss
413413+ }
414414+415415+ if err != nil {
416416+ return false, err
417417+ }
418418+419419+ // Check if approval has expired
420420+ if time.Now().After(expiresAt) {
421421+ // Expired - clean up
422422+ _ = a.deleteCachedApproval(holdDID, userDID)
423423+ return false, nil
424424+ }
425425+426426+ return true, nil
427427+}
428428+429429+// cacheApproval stores a crew approval with TTL
430430+func (a *RemoteHoldAuthorizer) cacheApproval(holdDID, userDID string, ttl time.Duration) error {
431431+ query := `
432432+ INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
433433+ VALUES (?, ?, ?, ?)
434434+ ON CONFLICT(hold_did, user_did) DO UPDATE SET
435435+ approved_at = excluded.approved_at,
436436+ expires_at = excluded.expires_at
437437+ `
438438+439439+ now := time.Now()
440440+ expiresAt := now.Add(ttl)
441441+442442+ _, err := a.db.Exec(query, holdDID, userDID, now, expiresAt)
443443+ return err
444444+}
445445+446446+// deleteCachedApproval removes an expired approval
447447+func (a *RemoteHoldAuthorizer) deleteCachedApproval(holdDID, userDID string) error {
448448+ query := `DELETE FROM hold_crew_approvals WHERE hold_did = ? AND user_did = ?`
449449+ _, err := a.db.Exec(query, holdDID, userDID)
450450+ return err
451451+}
452452+453453+// isBlockedByDenialBackoff checks if user is in denial backoff period
454454+// Checks in-memory cache first (for 10s first denials), then DB (for longer backoffs)
455455+func (a *RemoteHoldAuthorizer) isBlockedByDenialBackoff(holdDID, userDID string) (bool, error) {
456456+ // Check in-memory cache first (first denials with 10s backoff)
457457+ key := fmt.Sprintf("%s:%s", holdDID, userDID)
458458+ if val, ok := a.recentDenials.Load(key); ok {
459459+ entry := val.(denialEntry)
460460+ // Check if still within 10s backoff
461461+ if time.Since(entry.timestamp) < 10*time.Second {
462462+ return true, nil // Still blocked by in-memory first denial
463463+ }
464464+ }
465465+466466+ // Check database for longer backoffs (second+ denials)
467467+ query := `
468468+ SELECT next_retry_at
469469+ FROM hold_crew_denials
470470+ WHERE hold_did = ? AND user_did = ?
471471+ `
472472+473473+ var nextRetryAt time.Time
474474+ err := a.db.QueryRow(query, holdDID, userDID).Scan(&nextRetryAt)
475475+476476+ if err == sql.ErrNoRows {
477477+ return false, nil // No denial record
478478+ }
479479+480480+ if err != nil {
481481+ return false, err
482482+ }
483483+484484+ // Check if still in backoff period
485485+ if time.Now().Before(nextRetryAt) {
486486+ return true, nil // Still blocked
487487+ }
488488+489489+ // Backoff period expired - can retry
490490+ return false, nil
491491+}
492492+493493+// cacheDenial stores or updates a denial with exponential backoff
494494+// First denial: in-memory only (10s backoff)
495495+// Second+ denial: database with exponential backoff (1m, 5m, 15m, 1h)
496496+func (a *RemoteHoldAuthorizer) cacheDenial(holdDID, userDID string) error {
497497+ key := fmt.Sprintf("%s:%s", holdDID, userDID)
498498+499499+ // Check if this is a first denial (not in memory, not in DB)
500500+ _, inMemory := a.recentDenials.Load(key)
501501+502502+ var denialCount int
503503+ query := `SELECT denial_count FROM hold_crew_denials WHERE hold_did = ? AND user_did = ?`
504504+ err := a.db.QueryRow(query, holdDID, userDID).Scan(&denialCount)
505505+506506+ inDB := err != sql.ErrNoRows
507507+ if err != nil && err != sql.ErrNoRows {
508508+ return err
509509+ }
510510+511511+ // If not in memory and not in DB, this is the first denial
512512+ if !inMemory && !inDB {
513513+ // First denial: store only in memory with 10s backoff
514514+ a.recentDenials.Store(key, denialEntry{timestamp: time.Now()})
515515+ return nil
516516+ }
517517+518518+ // Second+ denial: persist to database with exponential backoff
519519+ denialCount++
520520+ backoff := getBackoffDuration(denialCount)
521521+ now := time.Now()
522522+ nextRetry := now.Add(backoff)
523523+524524+ // Upsert denial record
525525+ upsertQuery := `
526526+ INSERT INTO hold_crew_denials (hold_did, user_did, denial_count, next_retry_at, last_denied_at)
527527+ VALUES (?, ?, ?, ?, ?)
528528+ ON CONFLICT(hold_did, user_did) DO UPDATE SET
529529+ denial_count = excluded.denial_count,
530530+ next_retry_at = excluded.next_retry_at,
531531+ last_denied_at = excluded.last_denied_at
532532+ `
533533+534534+ _, err = a.db.Exec(upsertQuery, holdDID, userDID, denialCount, nextRetry, now)
535535+536536+ // Remove from in-memory cache since we're now tracking in DB
537537+ a.recentDenials.Delete(key)
538538+539539+ return err
540540+}
541541+542542+// getBackoffDuration returns the backoff duration based on denial count
543543+// Note: First denial (10s) is in-memory only and not tracked by this function
544544+// This function handles second+ denials: 1m, 5m, 15m, 1h
545545+func getBackoffDuration(denialCount int) time.Duration {
546546+ backoffs := []time.Duration{
547547+ 1 * time.Minute, // 1st DB denial (2nd overall) - being added soon
548548+ 5 * time.Minute, // 2nd DB denial (3rd overall) - probably not happening
549549+ 15 * time.Minute, // 3rd DB denial (4th overall) - definitely not soon
550550+ 60 * time.Minute, // 4th+ DB denial (5th+ overall) - stop hammering
551551+ }
552552+553553+ idx := denialCount - 1
554554+ if idx >= len(backoffs) {
555555+ idx = len(backoffs) - 1
556556+ }
557557+558558+ return backoffs[idx]
559559+}
+12-10
pkg/auth/oauth/server.go
···27272828// Server handles OAuth authorization for the AppView
2929type Server struct {
3030- app *App
3131- refresher *Refresher
3232- uiSessionStore UISessionStore
3333- db *sql.DB
3434- defaultHoldEndpoint string
3030+ app *App
3131+ refresher *Refresher
3232+ uiSessionStore UISessionStore
3333+ db *sql.DB
3434+ defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
3535}
36363737// NewServer creates a new OAuth server
···4141 }
4242}
43434444-// SetDefaultHoldEndpoint sets the default hold endpoint for profile creation
4545-func (s *Server) SetDefaultHoldEndpoint(endpoint string) {
4646- s.defaultHoldEndpoint = endpoint
4444+// SetDefaultHoldDID sets the default hold DID for profile creation
4545+// Expected format: "did:web:hold01.atcr.io"
4646+// To find a hold's DID, visit: https://hold-url/.well-known/did.json
4747+func (s *Server) SetDefaultHoldDID(did string) {
4848+ s.defaultHoldDID = did
4749}
48504951// SetRefresher sets the refresher for invalidating session cache
···271273 client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
272274273275 // 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.defaultHoldEndpoint)
275275- if err := atproto.EnsureProfile(ctx, client, s.defaultHoldEndpoint); err != nil {
276276+ fmt.Printf("DEBUG [oauth/server]: Ensuring profile exists for %s (defaultHold=%s)\n", did, s.defaultHoldDID)
277277+ if err := atproto.EnsureProfile(ctx, client, s.defaultHoldDID); err != nil {
276278 fmt.Printf("WARNING [oauth/server]: Failed to ensure profile for %s: %v\n", did, err)
277279 // Continue anyway - profile creation is not critical for avatar fetch
278280 } else {
+12-10
pkg/auth/token/handler.go
···18181919// Handler handles /auth/token requests
2020type Handler struct {
2121- issuer *Issuer
2222- validator *atproto.SessionValidator
2323- deviceStore *db.DeviceStore // For validating device secrets
2424- defaultHoldEndpoint string
2121+ issuer *Issuer
2222+ validator *atproto.SessionValidator
2323+ deviceStore *db.DeviceStore // For validating device secrets
2424+ defaultHoldDID string
2525}
26262727// NewHandler creates a new token handler
2828-func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldEndpoint string) *Handler {
2828+// defaultHoldDID should be in format "did:web:hold01.atcr.io"
2929+// To find a hold's DID, visit: https://hold-url/.well-known/did.json
3030+func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldDID string) *Handler {
2931 return &Handler{
3030- issuer: issuer,
3131- validator: atproto.NewSessionValidator(),
3232- deviceStore: deviceStore,
3333- defaultHoldEndpoint: defaultHoldEndpoint,
3232+ issuer: issuer,
3333+ validator: atproto.NewSessionValidator(),
3434+ deviceStore: deviceStore,
3535+ defaultHoldDID: defaultHoldDID,
3436 }
3537}
3638···157159 atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
158160159161 // Ensure profile exists (will create with default hold if not exists and default is configured)
160160- if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
162162+ if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldDID); err != nil {
161163 // Log error but don't fail auth - profile management is not critical
162164 fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
163165 }
-181
pkg/hold/authorization.go
···11-package hold
22-33-import (
44- "context"
55- "encoding/json"
66- "fmt"
77- "log"
88- "time"
99-1010- "atcr.io/pkg/atproto"
1111- "github.com/bluesky-social/indigo/atproto/identity"
1212- "github.com/bluesky-social/indigo/atproto/syntax"
1313-)
1414-1515-// isAuthorizedRead checks if a DID can read from this hold
1616-// Authorization:
1717-// - Public hold: allow anonymous (empty DID) or any authenticated user
1818-// - Private hold: require authentication (any user with sailor.profile)
1919-func (s *HoldService) isAuthorizedRead(did string) bool {
2020- // Check hold public flag
2121- isPublic, err := s.isHoldPublic()
2222- if err != nil {
2323- log.Printf("ERROR: Failed to check hold public flag: %v", err)
2424- // Fail secure - deny access on error
2525- return false
2626- }
2727-2828- if isPublic {
2929- // Public hold - allow anyone (even anonymous)
3030- return true
3131- }
3232-3333- // Private hold - require authentication
3434- // Any authenticated user with sailor.profile can read
3535- if did == "" {
3636- // Anonymous user trying to access private hold
3737- return false
3838- }
3939-4040- // For MVP: assume DID presence means they have sailor.profile
4141- // Future: could query PDS to verify sailor.profile exists
4242- return true
4343-}
4444-4545-// isAuthorizedWrite checks if a DID can write to this hold
4646-// Authorization: must be hold owner OR crew member
4747-func (s *HoldService) isAuthorizedWrite(did string) bool {
4848- if did == "" {
4949- // Anonymous writes not allowed
5050- return false
5151- }
5252-5353- // Check if DID is the hold owner
5454- ownerDID := s.config.Registration.OwnerDID
5555- if ownerDID == "" {
5656- log.Printf("ERROR: Hold owner DID not configured")
5757- return false
5858- }
5959-6060- if did == ownerDID {
6161- // Owner always has write access
6262- return true
6363- }
6464-6565- // Check if DID is a crew member
6666- isCrew, err := s.isCrewMember(did)
6767- if err != nil {
6868- log.Printf("ERROR: Failed to check crew membership: %v", err)
6969- return false
7070- }
7171-7272- return isCrew
7373-}
7474-7575-// isHoldPublic checks if this hold allows public (anonymous) reads
7676-func (s *HoldService) isHoldPublic() (bool, error) {
7777- // Use cached config value for now
7878- // Future: could query PDS for hold record to get live value
7979- return s.config.Server.Public, nil
8080-}
8181-8282-// isCrewMember checks if a DID is a crew member of this hold
8383-// Supports both explicit DID matching and pattern-based matching (wildcards, handle globs)
8484-func (s *HoldService) isCrewMember(did string) (bool, error) {
8585- ownerDID := s.config.Registration.OwnerDID
8686- if ownerDID == "" {
8787- return false, fmt.Errorf("hold owner DID not configured")
8888- }
8989-9090- ctx := context.Background()
9191-9292- // Resolve owner's PDS endpoint using indigo
9393- directory := identity.DefaultDirectory()
9494- ownerDIDParsed, err := syntax.ParseDID(ownerDID)
9595- if err != nil {
9696- return false, fmt.Errorf("invalid owner DID: %w", err)
9797- }
9898-9999- ident, err := directory.LookupDID(ctx, ownerDIDParsed)
100100- if err != nil {
101101- return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
102102- }
103103-104104- pdsEndpoint := ident.PDSEndpoint()
105105- if pdsEndpoint == "" {
106106- return false, fmt.Errorf("no PDS endpoint found for owner")
107107- }
108108-109109- // Build this hold's URI for filtering
110110- publicURL := s.config.Server.PublicURL
111111- if publicURL == "" {
112112- return false, fmt.Errorf("hold public URL not configured")
113113- }
114114- holdName, err := extractHostname(publicURL)
115115- if err != nil {
116116- return false, fmt.Errorf("failed to extract hold name: %w", err)
117117- }
118118- holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
119119-120120- // Create unauthenticated client to read public records
121121- client := atproto.NewClient(pdsEndpoint, ownerDID, "")
122122-123123- // List crew records for this hold
124124- // Crew records are public, so we can read them without auth
125125- records, err := client.ListRecords(ctx, atproto.HoldCrewCollection, 100)
126126- if err != nil {
127127- return false, fmt.Errorf("failed to list crew records: %w", err)
128128- }
129129-130130- // Resolve handle once for pattern matching (lazily, only if needed)
131131- var handle string
132132- var handleResolved bool
133133-134134- // Check crew records for both explicit DID and pattern matches
135135- for _, record := range records {
136136- var crewRecord atproto.HoldCrewRecord
137137- if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
138138- continue
139139- }
140140-141141- // Only check crew records for THIS hold (prevents cross-hold access)
142142- if crewRecord.Hold != holdURI {
143143- continue
144144- }
145145-146146- // Check expiration (if set)
147147- if crewRecord.ExpiresAt != nil && time.Now().After(*crewRecord.ExpiresAt) {
148148- continue // Skip expired membership
149149- }
150150-151151- // Check explicit DID match
152152- if crewRecord.Member != nil && *crewRecord.Member == did {
153153- // Found explicit crew membership
154154- return true, nil
155155- }
156156-157157- // Check pattern match (if pattern is set)
158158- if crewRecord.MemberPattern != nil && *crewRecord.MemberPattern != "" {
159159- // Lazy handle resolution - only resolve if we encounter a pattern
160160- if !handleResolved {
161161- handle, err = resolveHandle(did)
162162- if err != nil {
163163- log.Printf("Warning: failed to resolve handle for DID %s: %v", did, err)
164164- // Continue checking explicit DIDs even if handle resolution fails
165165- handleResolved = true // Mark as attempted (don't retry)
166166- handle = "" // Empty handle won't match patterns
167167- } else {
168168- handleResolved = true
169169- }
170170- }
171171-172172- // If we have a handle, check pattern match
173173- if handle != "" && matchPattern(*crewRecord.MemberPattern, handle) {
174174- // Found pattern-based crew membership
175175- return true, nil
176176- }
177177- }
178178- }
179179-180180- return false, nil
181181-}
···11-package pds
11+package hold
2233import (
44 "context"
5566- "atcr.io/pkg/hold"
66+ "atcr.io/pkg/hold/pds"
77)
8899-// HoldServiceBlobStore adapts the hold service to implement the BlobStore interface
99+// HoldServiceBlobStore adapts the hold service to implement the pds.BlobStore interface
1010type HoldServiceBlobStore struct {
1111- service *hold.HoldService
1111+ service *HoldService
1212 holdDID string
1313}
14141515// NewHoldServiceBlobStore creates a blob store adapter for the hold service
1616-func NewHoldServiceBlobStore(service *hold.HoldService, holdDID string) *HoldServiceBlobStore {
1616+func NewHoldServiceBlobStore(service *HoldService, holdDID string) pds.BlobStore {
1717 return &HoldServiceBlobStore{
1818 service: service,
1919 holdDID: holdDID,
···2323// GetPresignedDownloadURL returns a presigned URL for downloading a blob
2424func (b *HoldServiceBlobStore) GetPresignedDownloadURL(digest string) (string, error) {
2525 // Use the hold service's existing presigned URL logic
2626- // We need to expose a wrapper method on HoldService
2726 ctx := context.Background()
2828- url, err := b.service.GetPresignedURL(ctx, hold.OperationGet, digest, b.holdDID)
2727+ url, err := b.service.GetPresignedURL(ctx, OperationGet, digest, b.holdDID)
2928 if err != nil {
3029 return "", err
3130 }
···3635func (b *HoldServiceBlobStore) GetPresignedUploadURL(digest string) (string, error) {
3736 // Use the hold service's existing presigned URL logic
3837 ctx := context.Background()
3939- url, err := b.service.GetPresignedURL(ctx, hold.OperationPut, digest, b.holdDID)
3838+ url, err := b.service.GetPresignedURL(ctx, OperationPut, digest, b.holdDID)
4039 if err != nil {
4140 return "", err
4241 }
+12-13
pkg/hold/pds/captain.go
···66 "fmt"
77 "time"
8899+ "atcr.io/pkg/atproto"
910 "github.com/bluesky-social/indigo/repo"
1011 "github.com/ipfs/go-cid"
1112)
···17181819// CreateCaptainRecord creates the captain record for the hold
1920func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) (cid.Cid, error) {
2020- captainRecord := &CaptainRecord{
2121- Type: CaptainCollection,
2121+ captainRecord := &atproto.CaptainRecord{
2222+ Type: atproto.CaptainCollection,
2223 Owner: ownerDID,
2324 Public: public,
2425 AllowAllCrew: allowAllCrew,
···2627 }
27282829 // Create record in repo with fixed rkey "self"
2929- recordCID, rkey, err := p.repo.CreateRecord(ctx, CaptainCollection, captainRecord)
3030+ recordCID, rkey, err := p.repo.CreateRecord(ctx, atproto.CaptainCollection, captainRecord)
3031 if err != nil {
3132 return cid.Undef, fmt.Errorf("failed to create captain record: %w", err)
3233 }
···4849 return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
4950 }
50515151- // Create a new session for the next operation
5252- rootStr := root.String()
5353- newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
5252+ // Create a new session for the next operation (use revision string, not CID)
5353+ newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rev)
5454 if err != nil {
5555 return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
5656 }
···7171}
72727373// GetCaptainRecord retrieves the captain record
7474-func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *CaptainRecord, error) {
7575- path := fmt.Sprintf("%s/%s", CaptainCollection, CaptainRkey)
7474+func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.CaptainRecord, error) {
7575+ path := fmt.Sprintf("%s/%s", atproto.CaptainCollection, CaptainRkey)
76767777 // Get the record bytes and decode manually
7878 recordCID, recBytes, err := p.repo.GetRecordBytes(ctx, path)
···8181 }
82828383 // Decode the CBOR bytes into our CaptainRecord type
8484- var captainRecord CaptainRecord
8484+ var captainRecord atproto.CaptainRecord
8585 if err := captainRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
8686 return cid.Undef, nil, fmt.Errorf("failed to decode captain record: %w", err)
8787 }
···102102 existing.AllowAllCrew = allowAllCrew
103103104104 // Update record in repo
105105- path := fmt.Sprintf("%s/%s", CaptainCollection, CaptainRkey)
105105+ path := fmt.Sprintf("%s/%s", atproto.CaptainCollection, CaptainRkey)
106106 recordCID, err := p.repo.UpdateRecord(ctx, path, existing)
107107 if err != nil {
108108 return cid.Undef, fmt.Errorf("failed to update captain record: %w", err)
···125125 return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
126126 }
127127128128- // Create a new session for the next operation
129129- rootStr := root.String()
130130- newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
128128+ // Create a new session for the next operation (use revision string, not CID)
129129+ newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rev)
131130 if err != nil {
132131 return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
133132 }
+1-1
pkg/hold/pds/cbor_gen.go
pkg/atproto/cbor_gen.go
···11// Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT.
2233-package pds
33+package atproto
4455import (
66 "fmt"
+12-12
pkg/hold/pds/crew.go
···77 "strings"
88 "time"
991010+ "atcr.io/pkg/atproto"
1011 "github.com/bluesky-social/indigo/repo"
1112 "github.com/ipfs/go-cid"
1213)
13141415// AddCrewMember adds a new crew member to the hold and commits to carstore
1516func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
1616- crewRecord := &CrewRecord{
1717- Type: CrewCollection,
1717+ crewRecord := &atproto.CrewRecord{
1818+ Type: atproto.CrewCollection,
1819 Member: memberDID,
1920 Role: role,
2021 Permissions: permissions,
···2223 }
23242425 // Create record in repo (using memberDID as rkey for easy lookup)
2525- recordCID, _, err := p.repo.CreateRecord(ctx, CrewCollection, crewRecord)
2626+ recordCID, _, err := p.repo.CreateRecord(ctx, atproto.CrewCollection, crewRecord)
2627 if err != nil {
2728 return cid.Undef, fmt.Errorf("failed to create crew record: %w", err)
2829 }
···4445 return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
4546 }
46474747- // Create a new session for the next operation (old session is now closed)
4848- rootStr := root.String()
4949- newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
4848+ // Create a new session for the next operation (use revision string, not CID)
4949+ newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rev)
5050 if err != nil {
5151 return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
5252 }
···6565}
66666767// GetCrewMember retrieves a crew member by their record key
6868-func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *CrewRecord, error) {
6969- path := fmt.Sprintf("%s/%s", CrewCollection, rkey)
6868+func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.CrewRecord, error) {
6969+ path := fmt.Sprintf("%s/%s", atproto.CrewCollection, rkey)
70707171 // Get the record bytes and decode manually (indigo doesn't know our custom type)
7272 recordCID, recBytes, err := p.repo.GetRecordBytes(ctx, path)
···7575 }
76767777 // Decode the CBOR bytes into our CrewRecord type
7878- var crewRecord CrewRecord
7878+ var crewRecord atproto.CrewRecord
7979 if err := crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
8080 return cid.Undef, nil, fmt.Errorf("failed to decode crew record: %w", err)
8181 }
···8787type CrewMemberWithKey struct {
8888 Rkey string
8989 Cid cid.Cid
9090- Record *CrewRecord
9090+ Record *atproto.CrewRecord
9191}
92929393// ListCrewMembers returns all crew members with their rkeys
9494func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, error) {
9595 var crew []*CrewMemberWithKey
96969797- err := p.repo.ForEach(ctx, CrewCollection, func(k string, v cid.Cid) error {
9797+ err := p.repo.ForEach(ctx, atproto.CrewCollection, func(k string, v cid.Cid) error {
9898 // Extract rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22")
9999 parts := strings.Split(k, "/")
100100 rkey := parts[len(parts)-1]
···127127128128// RemoveCrewMember removes a crew member
129129func (p *HoldPDS) RemoveCrewMember(ctx context.Context, rkey string) error {
130130- path := fmt.Sprintf("%s/%s", CrewCollection, rkey)
130130+ path := fmt.Sprintf("%s/%s", atproto.CrewCollection, rkey)
131131132132 err := p.repo.DeleteRecord(ctx, path)
133133 if err != nil {
+28-11
pkg/hold/pds/did.go
···44 "encoding/json"
55 "fmt"
66 "net/url"
77- "strings"
87)
98109// DIDDocument represents a did:web document
···35343635// GenerateDIDDocument creates a DID document for a did:web identity
3736func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
3838- // Extract hostname from public URL
3939- hostname := strings.TrimPrefix(publicURL, "http://")
4040- hostname = strings.TrimPrefix(hostname, "https://")
4141- hostname = strings.Split(hostname, "/")[0] // Remove any path
4242- hostname = strings.Split(hostname, ":")[0] // Remove port for DID
3737+ // Parse URL to extract host and port
3838+ u, err := url.Parse(publicURL)
3939+ if err != nil {
4040+ return nil, fmt.Errorf("failed to parse public URL: %w", err)
4141+ }
4242+4343+ hostname := u.Hostname()
4444+ port := u.Port()
4545+4646+ // Build host string (include non-standard ports per did:web spec)
4747+ host := hostname
4848+ if port != "" && port != "80" && port != "443" {
4949+ host = fmt.Sprintf("%s:%s", hostname, port)
5050+ }
43514444- did := fmt.Sprintf("did:web:%s", hostname)
5252+ did := fmt.Sprintf("did:web:%s", host)
45534654 // Get public key in multibase format using indigo's crypto
4755 pubKey, err := p.signingKey.PublicKey()
···5866 },
5967 ID: did,
6068 AlsoKnownAs: []string{
6161- fmt.Sprintf("at://%s", hostname),
6969+ fmt.Sprintf("at://%s", host),
6270 },
6371 VerificationMethod: []VerificationMethod{
6472 {
···99107}
100108101109// GenerateDIDFromURL creates a did:web identifier from a public URL
102102-// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com"
110110+// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080"
111111+// Note: Per did:web spec, non-standard ports (not 80/443) are included in the DID
103112func GenerateDIDFromURL(publicURL string) string {
104113 // Parse URL
105114 u, err := url.Parse(publicURL)
106115 if err != nil {
107116 // Fallback: assume it's just a hostname
108108- return fmt.Sprintf("did:web:%s", strings.Split(publicURL, ":")[0])
117117+ return fmt.Sprintf("did:web:%s", publicURL)
109118 }
110119111111- // Use hostname without port for DID
120120+ // Get hostname
112121 hostname := u.Hostname()
113122 if hostname == "" {
114123 hostname = "localhost"
124124+ }
125125+126126+ // Get port
127127+ port := u.Port()
128128+129129+ // Include port in DID if it's non-standard (not 80 for http, not 443 for https)
130130+ if port != "" && port != "80" && port != "443" {
131131+ return fmt.Sprintf("did:web:%s:%s", hostname, port)
115132 }
116133117134 return fmt.Sprintf("did:web:%s", hostname)
+88-16
pkg/hold/pds/server.go
···55 "fmt"
66 "os"
77 "path/filepath"
88+ "time"
891010+ "atcr.io/pkg/atproto"
911 "github.com/bluesky-social/indigo/atproto/atcrypto"
1012 "github.com/bluesky-social/indigo/carstore"
1113 "github.com/bluesky-social/indigo/models"
···5961 var session *carstore.DeltaSession
6062 var r *repo.Repo
61636262- // Create a session connected to this user's data in carstore
6363- session, err = cs.NewDeltaSession(ctx, uid, nil)
6464- if err != nil {
6565- return nil, fmt.Errorf("failed to create delta session: %w", err)
6666- }
6767-6864 if !hasValidRepo {
6969- // No valid repo - create new empty repo
6565+ // No valid repo - create new session with nil (new repo)
6666+ session, err = cs.NewDeltaSession(ctx, uid, nil)
6767+ if err != nil {
6868+ return nil, fmt.Errorf("failed to create delta session: %w", err)
6969+ }
7070+ // Create new empty repo
7071 r = repo.NewRepo(ctx, did, session)
7172 } else {
7272- // Repo exists with valid head - load from existing head
7373+ // Repo exists with valid head - create session pointing to current head
7474+ headStr := head.String()
7575+ session, err = cs.NewDeltaSession(ctx, uid, &headStr)
7676+ if err != nil {
7777+ return nil, fmt.Errorf("failed to create delta session: %w", err)
7878+ }
7979+ // Load from existing head
7380 r, err = repo.OpenRepo(ctx, session, head)
7481 if err != nil {
7582 return nil, fmt.Errorf("failed to open existing repo: %w", err)
···104111 return nil
105112 }
106113107107- // Check if repo already has commits
108108- head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
109109- if err != nil || !head.Defined() {
110110- // No repo exists yet, bootstrap
111111- fmt.Printf("🚀 Bootstrapping hold PDS with owner: %s\n", ownerDID)
112112- } else {
113113- // Repo exists and is valid
114114- fmt.Printf("⏭️ Skipping PDS bootstrap: repo already initialized (head: %s)\n", head.String()[:16])
114114+ // Check if captain record already exists (idempotent bootstrap)
115115+ _, _, err := p.GetCaptainRecord(ctx)
116116+ if err == nil {
117117+ // Captain record exists, we're good
118118+ fmt.Printf("✅ Captain record exists, skipping bootstrap\n")
115119 return nil
116120 }
117121122122+ // No captain record - check if this is a new repo or existing repo
123123+ head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
124124+ isNewRepo := (err != nil || !head.Defined())
125125+126126+ if isNewRepo {
127127+ fmt.Printf("🚀 Bootstrapping new hold PDS with owner: %s\n", ownerDID)
128128+ // For new repo, create records inline to avoid session issues
129129+ return p.bootstrapNewRepo(ctx, ownerDID, public, allowAllCrew)
130130+ }
131131+132132+ // Existing repo - use normal record creation flow
133133+ fmt.Printf("ℹ️ Repo already initialized (head: %s), creating captain record...\n", head.String()[:16])
134134+118135 // Create captain record (hold ownership and settings)
119136 _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew)
120137 if err != nil {
···130147 }
131148132149 fmt.Printf("✅ Added %s as hold admin\n", ownerDID)
150150+ return nil
151151+}
152152+153153+// bootstrapNewRepo handles bootstrapping a brand new repo (avoids session juggling issues)
154154+func (p *HoldPDS) bootstrapNewRepo(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) error {
155155+ // Create captain and crew records in a single commit
156156+ captainRecord := &atproto.CaptainRecord{
157157+ Type: atproto.CaptainCollection,
158158+ Owner: ownerDID,
159159+ Public: public,
160160+ AllowAllCrew: allowAllCrew,
161161+ DeployedAt: time.Now().Format(time.RFC3339),
162162+ }
163163+164164+ crewRecord := &atproto.CrewRecord{
165165+ Type: atproto.CrewCollection,
166166+ Member: ownerDID,
167167+ Role: "admin",
168168+ Permissions: []string{"blob:read", "blob:write", "crew:admin"},
169169+ AddedAt: time.Now().Format(time.RFC3339),
170170+ }
171171+172172+ // Create both records in the repo
173173+ _, _, err := p.repo.CreateRecord(ctx, atproto.CaptainCollection, captainRecord)
174174+ if err != nil {
175175+ return fmt.Errorf("failed to create captain record: %w", err)
176176+ }
177177+178178+ _, _, err = p.repo.CreateRecord(ctx, atproto.CrewCollection, crewRecord)
179179+ if err != nil {
180180+ return fmt.Errorf("failed to create crew record: %w", err)
181181+ }
182182+183183+ // Commit everything in one go
184184+ signer := func(ctx context.Context, did string, data []byte) ([]byte, error) {
185185+ return p.signingKey.HashAndSign(data)
186186+ }
187187+188188+ root, rev, err := p.repo.Commit(ctx, signer)
189189+ if err != nil {
190190+ return fmt.Errorf("failed to commit bootstrap records: %w", err)
191191+ }
192192+193193+ // Close the session with the new root
194194+ _, err = p.session.CloseWithRoot(ctx, root, rev)
195195+ if err != nil {
196196+ return fmt.Errorf("failed to persist bootstrap commit: %w", err)
197197+ }
198198+199199+ fmt.Printf("✅ Created captain record (public=%v, allowAllCrew=%v)\n", public, allowAllCrew)
200200+ fmt.Printf("✅ Added %s as hold admin\n", ownerDID)
201201+202202+ // DON'T create a new session here - let subsequent operations handle that
203203+ // The PDS is now bootstrapped and will be reloaded properly on next restart
204204+133205 return nil
134206}
135207
-32
pkg/hold/pds/types.go
···11-package pds
22-33-//go:generate go run github.com/whyrusleeping/cbor-gen --map-encoding CrewRecord CaptainRecord
44-55-// ATProto record types for the hold service
66-77-// CaptainRecord represents the hold's ownership and metadata
88-// Collection: io.atcr.hold.captain (single record per hold)
99-type CaptainRecord struct {
1010- Type string `json:"$type" cborgen:"$type"`
1111- Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
1212- Public bool `json:"public" cborgen:"public"` // Public read access
1313- AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
1414- DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
1515- Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
1616- Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
1717-}
1818-1919-// CrewRecord represents a crew member in the hold
2020-// Collection: io.atcr.hold.crew (one record per member)
2121-type CrewRecord struct {
2222- Type string `json:"$type" cborgen:"$type"`
2323- Member string `json:"member" cborgen:"member"`
2424- Role string `json:"role" cborgen:"role"`
2525- Permissions []string `json:"permissions" cborgen:"permissions"`
2626- AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
2727-}
2828-2929-const (
3030- CaptainCollection = "io.atcr.hold.captain"
3131- CrewCollection = "io.atcr.hold.crew"
3232-)
+6-5
pkg/hold/pds/xrpc.go
···77 "net/http"
88 "strings"
991010+ "atcr.io/pkg/atproto"
1011 "github.com/bluesky-social/indigo/repo"
1112 "github.com/bluesky-social/indigo/util"
1213 "github.com/ipfs/go-cid"
···151152 "did": h.pds.DID(),
152153 "handle": h.pds.DID(),
153154 "didDoc": didDoc,
154154- "collections": []string{CrewCollection},
155155+ "collections": []string{atproto.CrewCollection},
155156 "handleIsCorrect": true,
156157 }
157158···181182 }
182183183184 // Only support crew collection for now
184184- if collection != CrewCollection {
185185+ if collection != atproto.CrewCollection {
185186 http.Error(w, "collection not found", http.StatusNotFound)
186187 return
187188 }
···223224 }
224225225226 // Only support crew collection for now
226226- if collection != CrewCollection {
227227+ if collection != atproto.CrewCollection {
227228 http.Error(w, "collection not found", http.StatusNotFound)
228229 return
229230 }
···273274 }
274275275276 // Only support crew collection for now
276276- if collection != CrewCollection {
277277+ if collection != atproto.CrewCollection {
277278 http.Error(w, "collection not found", http.StatusNotFound)
278279 return
279280 }
···551552 if member.Record.Member == user.DID {
552553 // Already a crew member, return success with existing record
553554 response := map[string]any{
554554- "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), CrewCollection, member.Rkey),
555555+ "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey),
555556 "cid": member.Cid.String(),
556557 "status": "already_member",
557558 "message": "User is already a crew member",
+51-5
pkg/hold/service.go
···77 "net/http"
88 "net/url"
991010+ "atcr.io/pkg/auth"
1011 "github.com/aws/aws-sdk-go/service/s3"
1112 storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
1213 "github.com/distribution/distribution/v3/registry/storage/driver/factory"
1314)
1515+1616+// HoldPDSInterface is the minimal interface needed from the embedded PDS
1717+// This avoids a circular import between pkg/hold and pkg/hold/pds
1818+type HoldPDSInterface interface {
1919+ DID() string
2020+}
14211522// HoldService provides presigned URLs for blob storage in a hold
1623type HoldService struct {
1724 driver storagedriver.StorageDriver
1825 config *Config
1919- s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
2020- bucket string // S3 bucket name
2121- s3PathPrefix string // S3 path prefix (if any)
2222- MultipartMgr *MultipartManager // Exported for access in route handlers
2626+ s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
2727+ bucket string // S3 bucket name
2828+ s3PathPrefix string // S3 path prefix (if any)
2929+ MultipartMgr *MultipartManager // Exported for access in route handlers
3030+ pds HoldPDSInterface // Embedded PDS for captain/crew records
3131+ authorizer auth.HoldAuthorizer // Authorizer for access control
2332}
24332534// NewHoldService creates a new hold service
2626-func NewHoldService(cfg *Config) (*HoldService, error) {
3535+// holdPDS must be a *pds.HoldPDS but we use any to avoid import cycle
3636+func NewHoldService(cfg *Config, holdPDS any) (*HoldService, error) {
2737 // Create storage driver from config
2838 ctx := context.Background()
2939 driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
···3141 return nil, fmt.Errorf("failed to create storage driver: %w", err)
3242 }
33434444+ // Create local authorizer using the embedded PDS
4545+ // This requires casting holdPDS to the concrete type expected by auth
4646+ authorizer := auth.NewLocalHoldAuthorizerFromInterface(holdPDS)
4747+4848+ // Cast to our interface for storage
4949+ pdsInterface, ok := holdPDS.(HoldPDSInterface)
5050+ if !ok {
5151+ return nil, fmt.Errorf("holdPDS must implement HoldPDSInterface")
5252+ }
5353+3454 service := &HoldService{
3555 driver: driver,
3656 config: cfg,
3757 MultipartMgr: NewMultipartManager(),
5858+ pds: pdsInterface,
5959+ authorizer: authorizer,
3860 }
39614062 // Initialize S3 client for presigned URLs (if using S3 storage)
···4870// GetPresignedURL is a public wrapper around getPresignedURL for use by PDS blob store
4971func (s *HoldService) GetPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
5072 return s.getPresignedURL(ctx, operation, digest, did)
7373+}
7474+7575+// isAuthorizedRead checks if the given DID has read access to this hold
7676+// This is a helper wrapper around the authorizer for internal use
7777+func (s *HoldService) isAuthorizedRead(did string) bool {
7878+ ctx := context.Background()
7979+ allowed, err := s.authorizer.CheckReadAccess(ctx, s.pds.DID(), did)
8080+ if err != nil {
8181+ log.Printf("Authorization check failed: %v", err)
8282+ return false
8383+ }
8484+ return allowed
8585+}
8686+8787+// isAuthorizedWrite checks if the given DID has write access to this hold
8888+// This is a helper wrapper around the authorizer for internal use
8989+func (s *HoldService) isAuthorizedWrite(did string) bool {
9090+ ctx := context.Background()
9191+ allowed, err := s.authorizer.CheckWriteAccess(ctx, s.pds.DID(), did)
9292+ if err != nil {
9393+ log.Printf("Authorization check failed: %v", err)
9494+ return false
9595+ }
9696+ return allowed
5197}
52985399// HealthHandler handles health check requests