crazy refactor to start using holds embedded pds for crew/captain validation

This commit is contained in:
Evan Jarrett
2025-10-16 00:05:45 -05:00
parent 08086e5afc
commit 70e802764b
30 changed files with 1530 additions and 368 deletions
+21 -1
View File
@@ -1,6 +1,8 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"os"
@@ -71,8 +73,22 @@ func buildHTTPConfig() (configuration.HTTP, error) {
addr := getEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
debugAddr := getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001")
// HTTP secret - only needed for multipart uploads in distribution's storage driver
// Since AppView is stateless and routes all storage through middleware, this isn't
// actually used, but we generate a random secret for defense in depth
httpSecret := os.Getenv("REGISTRY_HTTP_SECRET")
if httpSecret == "" {
// Generate a random 32-byte secret
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
return configuration.HTTP{}, fmt.Errorf("failed to generate random secret: %w", err)
}
httpSecret = hex.EncodeToString(randomBytes)
}
return configuration.HTTP{
Addr: addr,
Addr: addr,
Secret: httpSecret,
Headers: map[string][]string{
"X-Content-Type-Options": {"nosniff"},
},
@@ -108,12 +124,16 @@ func buildStorageConfig() configuration.Storage {
// buildMiddlewareConfig creates middleware configuration
func buildMiddlewareConfig(defaultHold string) map[string][]configuration.Middleware {
// Check test mode
testMode := os.Getenv("TEST_MODE") == "true"
return map[string][]configuration.Middleware{
"registry": {
{
Name: "atproto-resolver",
Options: configuration.Parameters{
"default_storage_endpoint": defaultHold,
"test_mode": testMode,
},
},
},
+41 -14
View File
@@ -20,6 +20,8 @@ import (
"github.com/spf13/cobra"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/token"
@@ -147,8 +149,20 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
metricsDB := db.NewMetricsDB(uiDatabase)
middleware.SetGlobalDatabase(metricsDB)
// 6.6. Create RemoteHoldAuthorizer for hold authorization with caching
holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase)
middleware.SetGlobalAuthorizer(holdAuthorizer)
fmt.Println("Hold authorizer initialized with database caching")
// 6.7. Extract default hold DID for OAuth server and backfill worker
// This is used to create sailor profiles on first login and cache captain records
// Expected format: "did:web:hold01.atcr.io"
// To find a hold's DID, visit: https://hold01.atcr.io/.well-known/did.json
// The extraction function normalizes URLs to DIDs for consistency
defaultHoldDID := extractDefaultHoldDID(config)
// 7. Initialize UI routes with OAuth app, refresher, and device store
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore)
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID)
// 8. Create OAuth server
oauthServer := oauth.NewServer(oauthApp)
@@ -161,12 +175,11 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Connect database for user avatar management
oauthServer.SetDatabase(uiDatabase)
// 8.5. Extract default hold endpoint and set it on OAuth server
// 8.5. Set default hold DID on OAuth server (extracted earlier)
// This is used to create sailor profiles on first login
defaultHoldEndpoint := extractDefaultHoldEndpoint(config)
if defaultHoldEndpoint != "" {
oauthServer.SetDefaultHoldEndpoint(defaultHoldEndpoint)
fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldEndpoint)
if defaultHoldDID != "" {
oauthServer.SetDefaultHoldDID(defaultHoldDID)
fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldDID)
}
// 9. Initialize auth keys and create token issuer
@@ -227,8 +240,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Mount auth endpoints if enabled
if issuer != nil {
// Basic Auth token endpoint (supports device secrets and app passwords)
// Reuse defaultHoldEndpoint extracted earlier
tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldEndpoint)
// Reuse defaultHoldDID extracted earlier
tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldDID)
tokenHandler.RegisterRoutes(mux)
// Device authorization endpoints (public)
@@ -351,8 +364,11 @@ func getIntParam(params configuration.Parameters, key string, defaultValue int)
return defaultValue
}
// extractDefaultHoldEndpoint extracts the default hold endpoint from middleware config
func extractDefaultHoldEndpoint(config *configuration.Configuration) string {
// extractDefaultHoldDID extracts the default hold DID from middleware config
// Returns a DID (e.g., "did:web:hold01.atcr.io") for consistency
// Accepts both DIDs and URLs in config for backward compatibility
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
func extractDefaultHoldDID(config *configuration.Configuration) string {
// Navigate through: middleware.registry[].options.default_storage_endpoint
registryMiddleware, ok := config.Middleware["registry"]
if !ok {
@@ -369,7 +385,9 @@ func extractDefaultHoldEndpoint(config *configuration.Configuration) string {
// Extract options - options is configuration.Parameters which is map[string]any
if mw.Options != nil {
if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok {
return endpoint
// Normalize to DID (handles both URLs and DIDs)
// This ensures we store DIDs consistently
return atproto.ResolveHoldDIDFromURL(endpoint)
}
}
}
@@ -447,7 +465,8 @@ func initializeDatabase() (*sql.DB, *sql.DB, *db.SessionStore) {
// initializeUIRoutes initializes the web UI routes
// database: read-write connection for auth and writes
// readOnlyDB: read-only connection for public queries (search, user pages, etc.)
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) {
// defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io")
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) {
// Check if UI is enabled
uiEnabled := os.Getenv("ATCR_UI_ENABLED")
if uiEnabled == "false" {
@@ -647,12 +666,20 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S
relayEndpoint = "https://relay1.us-east.bsky.network"
}
backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint)
// Check test mode
testMode := os.Getenv("TEST_MODE") == "true"
backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode)
if err != nil {
fmt.Printf("Warning: Failed to create backfill worker: %v\n", err)
} else {
// Run initial backfill
// Run initial backfill with startup delay for Docker compose
go func() {
// Wait for hold service to be ready (Docker startup race condition)
startupDelay := 5 * time.Second
fmt.Printf("Backfill: Waiting %s for services to be ready...\n", startupDelay)
time.Sleep(startupDelay)
fmt.Printf("Backfill: Starting sync-based backfill from %s...\n", relayEndpoint)
if err := backfillWorker.Start(context.Background()); err != nil {
fmt.Printf("Backfill: Finished with error: %v\n", err)
+16 -12
View File
@@ -23,13 +23,8 @@ func main() {
log.Fatalf("Failed to load config: %v", err)
}
// Create hold service
service, err := hold.NewHoldService(cfg)
if err != nil {
log.Fatalf("Failed to create hold service: %v", err)
}
// Initialize embedded PDS if database path is configured
// This must happen before creating HoldService since service needs PDS for authorization
var holdPDS *pds.HoldPDS
var xrpcHandler *pds.XRPCHandler
if cfg.Database.Path != "" {
@@ -49,13 +44,22 @@ func main() {
log.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Create blob store adapter
blobStore := pds.NewHoldServiceBlobStore(service, holdDID)
// Create XRPC handler
xrpcHandler = pds.NewXRPCHandler(holdPDS, cfg.Server.PublicURL, blobStore)
log.Printf("Embedded PDS initialized successfully")
} else {
log.Fatalf("Database path is required for embedded PDS authorization")
}
// Create hold service with PDS
service, err := hold.NewHoldService(cfg, holdPDS)
if err != nil {
log.Fatalf("Failed to create hold service: %v", err)
}
// Create blob store adapter and XRPC handler
if holdPDS != nil {
holdDID := holdPDS.DID()
blobStore := hold.NewHoldServiceBlobStore(service, holdDID)
xrpcHandler = pds.NewXRPCHandler(holdPDS, cfg.Server.PublicURL, blobStore)
}
// Setup HTTP routes
+3 -1
View File
@@ -13,10 +13,12 @@ services:
environment:
# Server configuration
ATCR_HTTP_ADDR: :5000
ATCR_DEFAULT_HOLD: http://atcr-hold:8080
ATCR_DEFAULT_HOLD: http://172.28.0.3:8080
# UI configuration
ATCR_UI_ENABLED: true
ATCR_BACKFILL_ENABLED: true
# Test mode - fallback to default hold when user's hold is unreachable
TEST_MODE: true
# Logging
ATCR_LOG_LEVEL: info
volumes:
+4 -4
View File
@@ -31,7 +31,7 @@ User approved Claude's plan:
4. Create Profile Management
File: pkg/atproto/profile.go (new file)
- EnsureProfile(ctx, client, defaultHoldEndpoint) function
- EnsureProfile(ctx, client, defaultHoldDID) function
- Logic: check if profile exists, create with default if not
5. Update Auth Handlers
@@ -39,7 +39,7 @@ User approved Claude's plan:
Files: pkg/auth/exchange/handler.go and pkg/auth/token/service.go
- Call EnsureProfile() after token validation
- Use authenticated client (has write access to user's PDS)
- Pass AppView's default_hold_endpoint config
- Pass AppView's default_hold_did config (format: "did:web:hold01.atcr.io")
6. Update Hold Resolution
@@ -89,8 +89,8 @@ Progress Summary
5. Updated /auth/exchange handler to manage profile
⏳ In Progress:
- Need to update /auth/token handler similarly (add defaultHoldEndpoint parameter and profile management)
- Fix compilation error in extractDefaultHoldEndpoint() - should use configuration.Middleware type not any
- Need to update /auth/token handler similarly (add defaultHoldDID parameter and profile management)
- Fix compilation error in extractDefaultHoldDID() - should use configuration.Middleware type not any
🔜 Remaining:
- Update findStorageEndpoint() for new priority logic (check profile → own hold → default)
+7 -7
View File
@@ -7,8 +7,8 @@ package main
// Usage:
// go run gen/main.go
//
// This creates pkg/hold/pds/cbor_gen.go which should be committed to git.
// Only re-run when you modify types in pkg/hold/pds/types.go
// This creates pkg/atproto/cbor_gen.go which should be committed to git.
// Only re-run when you modify types in pkg/atproto/types.go
import (
"fmt"
@@ -16,18 +16,18 @@ import (
cbg "github.com/whyrusleeping/cbor-gen"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/atproto"
)
func main() {
// Generate map-style encoders for CrewRecord and CaptainRecord
if err := cbg.WriteMapEncodersToFile("pkg/hold/pds/cbor_gen.go", "pds",
pds.CrewRecord{},
pds.CaptainRecord{},
if err := cbg.WriteMapEncodersToFile("pkg/atproto/cbor_gen.go", "atproto",
atproto.CrewRecord{},
atproto.CaptainRecord{},
); err != nil {
fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
os.Exit(1)
}
fmt.Println("Generated CBOR encoders in pkg/hold/pds/cbor_gen.go")
fmt.Println("Generated CBOR encoders in pkg/atproto/cbor_gen.go")
}
@@ -0,0 +1,20 @@
description: Add crew cache tables for authorization with exponential backoff
query: |
CREATE TABLE IF NOT EXISTS hold_crew_approvals (
hold_did TEXT NOT NULL,
user_did TEXT NOT NULL,
approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY(hold_did, user_did)
);
CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at);
CREATE TABLE IF NOT EXISTS hold_crew_denials (
hold_did TEXT NOT NULL,
user_did TEXT NOT NULL,
denial_count INTEGER NOT NULL DEFAULT 1,
next_retry_at TIMESTAMP NOT NULL,
last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(hold_did, user_did)
);
CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
+19
View File
@@ -179,6 +179,25 @@ CREATE TABLE IF NOT EXISTS hold_captain_records (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
CREATE TABLE IF NOT EXISTS hold_crew_approvals (
hold_did TEXT NOT NULL,
user_did TEXT NOT NULL,
approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY(hold_did, user_did)
);
CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at);
CREATE TABLE IF NOT EXISTS hold_crew_denials (
hold_did TEXT NOT NULL,
user_did TEXT NOT NULL,
denial_count INTEGER NOT NULL DEFAULT 1,
next_retry_at TIMESTAMP NOT NULL,
last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(hold_did, user_did)
);
CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
`
// InitDB initializes the SQLite database with the schema
+181 -10
View File
@@ -17,9 +17,11 @@ import (
// BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data
type BackfillWorker struct {
db *sql.DB
client *atproto.Client
directory identity.Directory
db *sql.DB
client *atproto.Client
directory identity.Directory
defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io")
testMode bool // If true, suppress warnings for external holds
}
// BackfillState tracks backfill progress
@@ -34,14 +36,18 @@ type BackfillState struct {
}
// NewBackfillWorker creates a backfill worker using sync API
func NewBackfillWorker(database *sql.DB, relayEndpoint string) (*BackfillWorker, error) {
// defaultHoldDID should be in format "did:web:hold01.atcr.io"
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool) (*BackfillWorker, error) {
// Create client for relay - used only for listReposByCollection
client := atproto.NewClient(relayEndpoint, "", "")
return &BackfillWorker{
db: database,
client: client, // This points to the relay
directory: identity.DefaultDirectory(),
db: database,
client: client, // This points to the relay
directory: identity.DefaultDirectory(),
defaultHoldDID: defaultHoldDID,
testMode: testMode,
}, nil
}
@@ -49,10 +55,20 @@ func NewBackfillWorker(database *sql.DB, relayEndpoint string) (*BackfillWorker,
func (b *BackfillWorker) Start(ctx context.Context) error {
fmt.Println("Backfill: Starting sync-based backfill...")
// First, query and cache the default hold's captain record
if b.defaultHoldDID != "" {
fmt.Printf("Backfill: Querying default hold captain record: %s\n", b.defaultHoldDID)
if err := b.queryCaptainRecord(ctx, b.defaultHoldDID); err != nil {
fmt.Printf("WARNING: Failed to query default hold captain record: %v\n", err)
// Don't fail the whole backfill - just warn
}
}
collections := []string{
atproto.ManifestCollection, // io.atcr.manifest
atproto.TagCollection, // io.atcr.tag
atproto.StarCollection, // io.atcr.sailor.star
atproto.ManifestCollection, // io.atcr.manifest
atproto.TagCollection, // io.atcr.tag
atproto.StarCollection, // io.atcr.sailor.star
atproto.SailorProfileCollection, // io.atcr.sailor.profile
}
for _, collection := range collections {
@@ -267,6 +283,8 @@ func (b *BackfillWorker) processRecord(ctx context.Context, did, collection stri
return b.processTagRecord(did, record)
case atproto.StarCollection:
return b.processStarRecord(did, record)
case atproto.SailorProfileCollection:
return b.processSailorProfileRecord(ctx, did, record)
default:
return fmt.Errorf("unsupported collection: %s", collection)
}
@@ -364,6 +382,159 @@ func (b *BackfillWorker) processStarRecord(did string, record *atproto.Record) e
return db.UpsertStar(b.db, did, starRecord.Subject.DID, starRecord.Subject.Repository, starRecord.CreatedAt)
}
// processSailorProfileRecord processes a sailor profile record
// Extracts defaultHold and queries the hold's captain record to cache it
func (b *BackfillWorker) processSailorProfileRecord(ctx context.Context, did string, record *atproto.Record) error {
var profileRecord atproto.SailorProfileRecord
if err := json.Unmarshal(record.Value, &profileRecord); err != nil {
return fmt.Errorf("failed to unmarshal sailor profile: %w", err)
}
// Skip if no default hold set
if profileRecord.DefaultHold == "" {
return nil
}
// Convert hold URL/DID to canonical DID
holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold)
if holdDID == "" {
fmt.Printf("WARNING [backfill]: Invalid hold reference in profile for %s: %s\n", did, profileRecord.DefaultHold)
return nil
}
// Query and cache the captain record
if err := b.queryCaptainRecord(ctx, holdDID); err != nil {
// In test mode, only warn about default hold (local hold)
// External/production holds may not have captain records yet (dev ahead of prod)
if b.testMode && holdDID != b.defaultHoldDID {
// Suppress warning for external holds in test mode
return nil
}
fmt.Printf("WARNING [backfill]: Failed to query captain record for hold %s: %v\n", holdDID, err)
// Don't fail the whole backfill - just skip this hold
return nil
}
return nil
}
// queryCaptainRecord queries a hold's captain record and caches it in the database
func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string) error {
// Check if we already have it cached (skip if recently updated)
existing, err := db.GetCaptainRecord(b.db, holdDID)
if err == nil && existing != nil {
// If cached within last hour, skip refresh
if time.Since(existing.UpdatedAt) < 1*time.Hour {
return nil
}
}
// Resolve hold DID to URL
// For did:web, we need to fetch .well-known/did.json
holdURL, err := resolveHoldDIDToURL(ctx, holdDID)
if err != nil {
return fmt.Errorf("failed to resolve hold DID to URL: %w", err)
}
// Create client for hold's PDS
holdClient := atproto.NewClient(holdURL, holdDID, "")
// Query captain record with retries (for Docker startup timing)
var record *atproto.Record
maxRetries := 3
for attempt := 1; attempt <= maxRetries; attempt++ {
record, err = holdClient.GetRecord(ctx, "io.atcr.hold.captain", "self")
if err == nil {
break
}
// Retry on connection errors (hold service might still be starting)
if attempt < maxRetries && strings.Contains(err.Error(), "connection refused") {
fmt.Printf("Backfill: Hold not ready (attempt %d/%d), retrying in 2s...\n", attempt, maxRetries)
time.Sleep(2 * time.Second)
continue
}
return fmt.Errorf("failed to get captain record: %w", err)
}
// Parse captain record from the record's Value field
var captainRecord struct {
Owner string `json:"owner"`
Public bool `json:"public"`
AllowAllCrew bool `json:"allowAllCrew"`
DeployedAt string `json:"deployedAt"`
Region string `json:"region"`
Provider string `json:"provider"`
}
if err := json.Unmarshal(record.Value, &captainRecord); err != nil {
return fmt.Errorf("failed to parse captain record: %w", err)
}
// Cache in database
dbRecord := &db.HoldCaptainRecord{
HoldDID: holdDID,
OwnerDID: captainRecord.Owner,
Public: captainRecord.Public,
AllowAllCrew: captainRecord.AllowAllCrew,
DeployedAt: captainRecord.DeployedAt,
Region: captainRecord.Region,
Provider: captainRecord.Provider,
UpdatedAt: time.Now(),
}
if err := db.UpsertCaptainRecord(b.db, dbRecord); err != nil {
return fmt.Errorf("failed to cache captain record: %w", err)
}
fmt.Printf("Backfill: Cached captain record for hold %s (owner: %s)\n", holdDID, captainRecord.Owner)
return nil
}
// resolveHoldDIDToURL resolves a hold DID to its service endpoint URL
// Fetches the DID document and returns both the canonical DID and service endpoint
func resolveHoldDIDToURL(ctx context.Context, inputDID string) (string, error) {
// For did:web, construct the .well-known URL
if !strings.HasPrefix(inputDID, "did:web:") {
return "", fmt.Errorf("only did:web is supported, got: %s", inputDID)
}
// Extract hostname from did:web:hostname[:port]
hostname := strings.TrimPrefix(inputDID, "did:web:")
// Try HTTP first (for local Docker), then HTTPS
var serviceEndpoint string
for _, scheme := range []string{"http", "https"} {
testURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, hostname)
// Fetch DID document (use NewClient to initialize httpClient)
client := atproto.NewClient("", "", "")
didDoc, err := client.FetchDIDDocument(ctx, testURL)
if err == nil && didDoc != nil {
// Extract service endpoint from DID document
for _, service := range didDoc.Service {
if service.Type == "AtprotoPersonalDataServer" || service.Type == "AtcrHoldService" {
serviceEndpoint = service.ServiceEndpoint
break
}
}
if serviceEndpoint != "" {
fmt.Printf("DEBUG [backfill]: Resolved %s → canonical DID: %s, endpoint: %s\n",
inputDID, didDoc.ID, serviceEndpoint)
return serviceEndpoint, nil
}
}
}
// Fallback: assume the hold service is at the root of the hostname
// Try HTTP first for local development
url := fmt.Sprintf("http://%s", hostname)
fmt.Printf("WARNING [backfill]: Failed to fetch DID document for %s, using fallback URL: %s\n", inputDID, url)
return url, nil
}
// ensureUser resolves and upserts a user by DID
func (b *BackfillWorker) ensureUser(ctx context.Context, did string) error {
// Check if user already exists
+51 -5
View File
@@ -29,6 +29,9 @@ var globalDatabase interface {
IncrementPushCount(did, repository string) error
}
// Global authorizer instance (set by main.go for hold authorization)
var globalAuthorizer auth.HoldAuthorizer
// SetGlobalRefresher sets the global OAuth refresher instance
func SetGlobalRefresher(refresher *oauth.Refresher) {
globalRefresher = refresher
@@ -42,6 +45,11 @@ func SetGlobalDatabase(database interface {
globalDatabase = database
}
// SetGlobalAuthorizer sets the global authorizer instance for hold access control
func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) {
globalAuthorizer = authorizer
}
func init() {
// Register the name resolution middleware
registrymw.Register("atproto-resolver", initATProtoResolver)
@@ -52,6 +60,7 @@ type NamespaceResolver struct {
distribution.Namespace
directory identity.Directory
defaultStorageEndpoint string
testMode bool // If true, fallback to default hold when user's hold is unreachable
repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
}
@@ -61,15 +70,24 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
directory := identity.DefaultDirectory()
// Get default storage endpoint from config (optional)
// Normalize to DID format for consistency
defaultStorageEndpoint := ""
if endpoint, ok := options["default_storage_endpoint"].(string); ok {
defaultStorageEndpoint = endpoint
// Convert URL to DID if needed (or pass through if already a DID)
defaultStorageEndpoint = atproto.ResolveHoldDIDFromURL(endpoint)
}
// Check test mode from options (passed via env var)
testMode := false
if tm, ok := options["test_mode"].(bool); ok {
testMode = tm
}
return &NamespaceResolver{
Namespace: ns,
directory: directory,
defaultStorageEndpoint: defaultStorageEndpoint,
testMode: testMode,
}, nil
}
@@ -177,8 +195,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
// Create routing repository - routes manifests to ATProto, blobs to hold service
// The registry is stateless - no local storage is used
// Pass storage endpoint and DID as parameters (can't use context as it gets lost)
routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase)
// Pass storage endpoint, DID, and authorizer as parameters (can't use context as it gets lost)
routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase, globalAuthorizer)
// Cache the repository
nr.repositories.Store(cacheKey, routingRepo)
@@ -206,7 +224,8 @@ func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter {
// 1. User's sailor profile defaultHold (if set)
// 2. User's own hold record (io.atcr.hold)
// 3. AppView's default hold endpoint
// Returns the storage endpoint URL, or empty string if none configured
// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
// Note: Despite returning a DID, this is used as the "storage endpoint" throughout the code
func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEndpoint string) string {
// Create ATProto client (without auth - reading public records)
client := atproto.NewClient(pdsEndpoint, did, "")
@@ -219,7 +238,15 @@ func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEn
}
if profile != nil && profile.DefaultHold != "" {
// Profile exists with defaultHold set - use it
// Profile exists with defaultHold set
// In test mode, verify it's reachable before using it
if nr.testMode {
if nr.isHoldReachable(ctx, profile.DefaultHold) {
return profile.DefaultHold
}
fmt.Printf("DEBUG [registry/middleware/testmode]: User's defaultHold %s unreachable, falling back to default\n", profile.DefaultHold)
return nr.defaultStorageEndpoint
}
return profile.DefaultHold
}
@@ -247,3 +274,22 @@ func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEn
// 3. No profile defaultHold and no own hold records - use AppView default
return nr.defaultStorageEndpoint
}
// isHoldReachable checks if a hold service is reachable
// Used in test mode to fallback to default hold when user's hold is unavailable
func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string) bool {
// Try to fetch the DID document
hostname := strings.TrimPrefix(holdDID, "did:web:")
// Try HTTP first (local), then HTTPS
for _, scheme := range []string{"http", "https"} {
testURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, hostname)
client := atproto.NewClient("", "", "")
_, err := client.FetchDIDDocument(ctx, testURL)
if err == nil {
return true
}
}
return false
}
+80 -2
View File
@@ -10,6 +10,8 @@ import (
"sync"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"github.com/distribution/distribution/v3"
"github.com/opencontainers/go-digest"
)
@@ -34,11 +36,17 @@ type ProxyBlobStore struct {
did string
database DatabaseMetrics
repository string
authorizer auth.HoldAuthorizer
holdDID string
}
// NewProxyBlobStore creates a new proxy blob store
func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, repository string) *ProxyBlobStore {
fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, did=%s, repo=%s\n", storageEndpoint, did, repository)
func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore {
// Convert storage endpoint URL to did:web DID for authorization
holdDID := atproto.ResolveHoldDIDFromURL(storageEndpoint)
fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, holdDID=%s, userDID=%s, repo=%s\n",
storageEndpoint, holdDID, did, repository)
return &ProxyBlobStore{
storageEndpoint: storageEndpoint,
httpClient: &http.Client{
@@ -54,11 +62,56 @@ func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, re
did: did,
database: database,
repository: repository,
authorizer: authorizer,
holdDID: holdDID,
}
}
// checkReadAccess verifies the user has read access to the hold
func (p *ProxyBlobStore) checkReadAccess(ctx context.Context) error {
if p.authorizer == nil {
// No authorizer configured - allow access (backward compatibility)
return nil
}
hasAccess, err := p.authorizer.CheckReadAccess(ctx, p.holdDID, p.did)
if err != nil {
return fmt.Errorf("authorization check failed: %w", err)
}
if !hasAccess {
return distribution.ErrBlobUnknown // Return same error as missing blob for security
}
return nil
}
// checkWriteAccess verifies the user has write access to the hold
func (p *ProxyBlobStore) checkWriteAccess(ctx context.Context) error {
if p.authorizer == nil {
// No authorizer configured - allow access (backward compatibility)
return nil
}
hasAccess, err := p.authorizer.CheckWriteAccess(ctx, p.holdDID, p.did)
if err != nil {
return fmt.Errorf("authorization check failed: %w", err)
}
if !hasAccess {
return fmt.Errorf("write access denied to hold %s", p.holdDID)
}
return nil
}
// Stat returns the descriptor for a blob
func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) {
// Check read access
if err := p.checkReadAccess(ctx); err != nil {
return distribution.Descriptor{}, err
}
// Get presigned HEAD URL
url, err := p.getHeadURL(ctx, dgst)
if err != nil {
@@ -96,6 +149,11 @@ func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribu
// Get retrieves a blob
func (p *ProxyBlobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, error) {
// Check read access
if err := p.checkReadAccess(ctx); err != nil {
return nil, err
}
url, err := p.getDownloadURL(ctx, dgst)
if err != nil {
return nil, err
@@ -117,6 +175,11 @@ func (p *ProxyBlobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, e
// Open returns a reader for a blob
func (p *ProxyBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) {
// Check read access
if err := p.checkReadAccess(ctx); err != nil {
return nil, err
}
url, err := p.getDownloadURL(ctx, dgst)
if err != nil {
return nil, err
@@ -141,6 +204,11 @@ func (p *ProxyBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadS
// Put stores a blob
func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []byte) (distribution.Descriptor, error) {
// Check write access
if err := p.checkWriteAccess(ctx); err != nil {
return distribution.Descriptor{}, err
}
// Calculate digest
dgst := digest.FromBytes(content)
@@ -189,6 +257,11 @@ func (p *ProxyBlobStore) Delete(ctx context.Context, dgst digest.Digest) error {
// ServeBlob serves a blob via HTTP redirect
func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
// Check read access
if err := p.checkReadAccess(ctx); err != nil {
return err
}
// For HEAD requests, redirect to presigned HEAD URL
if r.Method == http.MethodHead {
url, err := p.getHeadURL(ctx, dgst)
@@ -214,6 +287,11 @@ func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r
// Create returns a blob writer for uploading using multipart upload
func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
// Check write access
if err := p.checkWriteAccess(ctx); err != nil {
return nil, err
}
// Parse options
var opts distribution.CreateOptions
for _, option := range options {
+6 -2
View File
@@ -6,6 +6,7 @@ import (
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"github.com/distribution/distribution/v3"
)
@@ -26,6 +27,7 @@ type RoutingRepository struct {
manifestStore *atproto.ManifestStore // Cached manifest store instance
blobStore *ProxyBlobStore // Cached blob store instance
database DatabaseMetrics // Database for metrics tracking
authorizer auth.HoldAuthorizer // Authorization for hold access
}
// NewRoutingRepository creates a new routing repository
@@ -36,6 +38,7 @@ func NewRoutingRepository(
storageEndpoint string,
did string,
database DatabaseMetrics,
authorizer auth.HoldAuthorizer,
) *RoutingRepository {
return &RoutingRepository{
Repository: baseRepo,
@@ -44,6 +47,7 @@ func NewRoutingRepository(
storageEndpoint: storageEndpoint,
did: did,
database: database,
authorizer: authorizer,
}
}
@@ -105,8 +109,8 @@ func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
panic("storage endpoint not set in RoutingRepository - ensure default_storage_endpoint is configured in middleware")
}
// Create and cache proxy blob store
r.blobStore = NewProxyBlobStore(holdEndpoint, r.did, r.database, r.repositoryName)
// Create and cache proxy blob store with authorization
r.blobStore = NewProxyBlobStore(holdEndpoint, r.did, r.database, r.repositoryName, r.authorizer)
return r.blobStore
}
@@ -1,6 +1,6 @@
// Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT.
package pds
package atproto
import (
"fmt"
+36
View File
@@ -625,3 +625,39 @@ func (c *Client) GetProfileRecord(ctx context.Context, did string) (*ProfileReco
func BlobCDNURL(didOrHandle, cid string) string {
return fmt.Sprintf("https://imgs.blue/%s/%s", didOrHandle, cid)
}
// DIDDocument represents a did:web document
type DIDDocument struct {
Context []string `json:"@context"`
ID string `json:"id"`
Service []struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
} `json:"service"`
}
// FetchDIDDocument fetches and parses a DID document from a URL
func (c *Client) FetchDIDDocument(ctx context.Context, didDocURL string) (*DIDDocument, error) {
req, err := http.NewRequestWithContext(ctx, "GET", didDocURL, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch DID document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch DID document failed with status %d", resp.StatusCode)
}
var didDoc DIDDocument
if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
return nil, fmt.Errorf("failed to decode DID document: %w", err)
}
return &didDoc, nil
}
+44 -1
View File
@@ -1,5 +1,7 @@
package atproto
//go:generate go run github.com/whyrusleeping/cbor-gen --map-encoding CrewRecord CaptainRecord
import (
"encoding/base64"
"encoding/json"
@@ -19,9 +21,19 @@ const (
// HoldCollection is the collection name for storage holds (BYOS)
HoldCollection = "io.atcr.hold"
// HoldCrewCollection is the collection name for hold crew (membership)
// HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model
// Stored in owner's PDS for BYOS holds
HoldCrewCollection = "io.atcr.hold.crew"
// CaptainCollection is the collection name for captain records (hold ownership) - EMBEDDED PDS model
// Stored in hold's embedded PDS (singleton record at rkey "self")
CaptainCollection = "io.atcr.hold.captain"
// CrewCollection is the collection name for crew records (access control) - EMBEDDED PDS model
// Stored in hold's embedded PDS (one record per member)
// Note: Uses same collection name as HoldCrewCollection but stored in different PDS (hold's PDS vs owner's PDS)
CrewCollection = "io.atcr.hold.crew"
// SailorProfileCollection is the collection name for user profiles
SailorProfileCollection = "io.atcr.sailor.profile"
@@ -371,3 +383,34 @@ func ResolveHoldDIDFromURL(holdURL string) string {
// did:web uses hostname directly (port included if non-standard)
return "did:web:" + hostname
}
// =============================================================================
// Embedded PDS Types (Hold Service)
// =============================================================================
// CaptainRecord represents the hold's ownership and metadata
// Collection: io.atcr.hold.captain (singleton record at rkey "self")
// Stored in the hold's embedded PDS to identify the hold owner and settings
// Uses CBOR encoding for efficient storage in hold's carstore
type CaptainRecord struct {
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
}
// CrewRecord represents a crew member in the hold
// Collection: io.atcr.hold.crew (one record per member)
// Stored in the hold's embedded PDS for access control
// Uses CBOR encoding for efficient storage in hold's carstore
// Note: Same collection name as HoldCrewRecord but stored in hold's PDS (not owner's PDS)
type CrewRecord struct {
Type string `json:"$type" cborgen:"$type"`
Member string `json:"member" cborgen:"member"`
Role string `json:"role" cborgen:"role"`
Permissions []string `json:"permissions" cborgen:"permissions"`
AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
}
+35 -5
View File
@@ -12,8 +12,10 @@ const ProfileRKey = "self"
// EnsureProfile checks if a user's profile exists and creates it if needed
// This should be called during authentication (OAuth exchange or token service)
// If defaultHoldEndpoint is provided, creates profile with that default (or empty if not provided)
func EnsureProfile(ctx context.Context, client *Client, defaultHoldEndpoint string) error {
// If defaultHoldDID is provided, creates profile with that default (or empty if not provided)
// Expected format: "did:web:hold01.atcr.io"
// Normalizes URLs to DIDs for consistency (for backward compatibility)
func EnsureProfile(ctx context.Context, client *Client, defaultHoldDID string) error {
// Check if profile already exists
profile, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
if err == nil && profile != nil {
@@ -21,21 +23,28 @@ func EnsureProfile(ctx context.Context, client *Client, defaultHoldEndpoint stri
return nil
}
// Normalize to DID if it's a URL (or pass through if already a DID)
// This ensures we store DIDs consistently in new profiles
normalizedDID := ""
if defaultHoldDID != "" {
normalizedDID = ResolveHoldDIDFromURL(defaultHoldDID)
}
// Profile doesn't exist - create it
// defaultHoldEndpoint can be empty string (user will need to configure it later)
newProfile := NewSailorProfileRecord(defaultHoldEndpoint)
newProfile := NewSailorProfileRecord(normalizedDID)
_, err = client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, newProfile)
if err != nil {
return fmt.Errorf("failed to create sailor profile: %w", err)
}
fmt.Printf("DEBUG [profile]: Created sailor profile with defaultHold=%s\n", defaultHoldEndpoint)
fmt.Printf("DEBUG [profile]: Created sailor profile with defaultHold=%s\n", normalizedDID)
return nil
}
// GetProfile retrieves the user's profile from their PDS
// Returns nil if profile doesn't exist
// Automatically migrates old URL-based defaultHold values to DIDs
func GetProfile(ctx context.Context, client *Client) (*SailorProfileRecord, error) {
record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
if err != nil {
@@ -52,11 +61,32 @@ func GetProfile(ctx context.Context, client *Client) (*SailorProfileRecord, erro
return nil, fmt.Errorf("failed to parse profile: %w", err)
}
// Migrate old URL-based defaultHold to DID format
// This ensures backward compatibility with profiles created before DID migration
if profile.DefaultHold != "" && !isDID(profile.DefaultHold) {
// Convert URL to DID transparently
profile.DefaultHold = ResolveHoldDIDFromURL(profile.DefaultHold)
fmt.Printf("DEBUG [profile]: Migrated defaultHold URL to DID: %s\n", profile.DefaultHold)
}
return &profile, nil
}
// isDID checks if a string is a DID (starts with "did:")
func isDID(s string) bool {
return len(s) > 4 && s[:4] == "did:"
}
// UpdateProfile updates the user's profile
// Normalizes defaultHold to DID format before saving
func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error {
// Normalize defaultHold to DID if it's a URL
// This ensures we always store DIDs, even if user provides a URL
if profile.DefaultHold != "" && !isDID(profile.DefaultHold) {
profile.DefaultHold = ResolveHoldDIDFromURL(profile.DefaultHold)
fmt.Printf("DEBUG [profile]: Normalized defaultHold to DID: %s\n", profile.DefaultHold)
}
_, err := client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, profile)
if err != nil {
return fmt.Errorf("failed to update profile: %w", err)
+77
View File
@@ -0,0 +1,77 @@
package auth
import (
"context"
"fmt"
"atcr.io/pkg/atproto"
)
// HoldAuthorizer checks if a DID has read/write access to a hold
// Implementations can query local PDS (hold service) or remote XRPC (appview)
type HoldAuthorizer interface {
// CheckReadAccess checks if userDID can read from holdDID
// Returns: (allowed bool, error)
CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error)
// CheckWriteAccess checks if userDID can write to holdDID
// Returns: (allowed bool, error)
CheckWriteAccess(ctx context.Context, holdDID, userDID string) (bool, error)
// GetCaptainRecord retrieves the captain record for a hold
// Used to check public flag and allowAllCrew settings
GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error)
// IsCrewMember checks if userDID is a crew member of holdDID
IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error)
}
// CheckReadAccessWithCaptain implements the standard read authorization logic
// This is shared across all HoldAuthorizer implementations
// Read access rules:
// - Public hold: allow anyone (even anonymous)
// - Private hold: require authentication (any authenticated user)
func CheckReadAccessWithCaptain(captain *atproto.CaptainRecord, userDID string) bool {
if captain.Public {
// Public hold - allow anyone (even anonymous)
return true
}
// Private hold - require authentication
// Any authenticated user with a DID can read
if userDID == "" {
// Anonymous user trying to access private hold
return false
}
// For MVP: assume DID presence means they have sailor.profile
// Future: could query PDS to verify sailor.profile exists
return true
}
// CheckWriteAccessWithCaptain implements the standard write authorization logic
// This is shared across all HoldAuthorizer implementations
// Write access rules:
// - Must be authenticated
// - Must be hold owner OR crew member
func CheckWriteAccessWithCaptain(captain *atproto.CaptainRecord, userDID string, isCrew bool) bool {
if userDID == "" {
// Anonymous writes not allowed
return false
}
// Check if DID is the hold owner
if userDID == captain.Owner {
// Owner always has write access
return true
}
// Check if DID is a crew member
return isCrew
}
// ErrHoldNotFound is returned when a hold's captain record cannot be found
var ErrHoldNotFound = fmt.Errorf("hold not found")
// ErrUnauthorized is returned when access is denied
var ErrUnauthorized = fmt.Errorf("unauthorized")
+101
View File
@@ -0,0 +1,101 @@
package auth
import (
"context"
"fmt"
"atcr.io/pkg/atproto"
"atcr.io/pkg/hold/pds"
)
// LocalHoldAuthorizer queries the hold's own embedded PDS directly
// Used by hold service to authorize access to its own storage
type LocalHoldAuthorizer struct {
pds *pds.HoldPDS
}
// NewLocalHoldAuthorizer creates a new local authorizer for hold service
func NewLocalHoldAuthorizer(holdPDS *pds.HoldPDS) HoldAuthorizer {
return &LocalHoldAuthorizer{
pds: holdPDS,
}
}
// NewLocalHoldAuthorizerFromInterface creates a new local authorizer from an any
// This is used to avoid import cycles - caller must pass a *pds.HoldPDS
func NewLocalHoldAuthorizerFromInterface(holdPDS any) HoldAuthorizer {
// Type assert to *pds.HoldPDS
if pdsTyped, ok := holdPDS.(*pds.HoldPDS); ok {
return &LocalHoldAuthorizer{
pds: pdsTyped,
}
}
// Return nil if type assertion fails - caller should check
return nil
}
// GetCaptainRecord retrieves the captain record from the hold's PDS
func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
// Verify that the requested holdDID matches this hold
if holdDID != a.pds.DID() {
return nil, fmt.Errorf("holdDID mismatch: requested %s, this hold is %s", holdDID, a.pds.DID())
}
// Query the PDS for captain record
_, pdsCaptain, err := a.pds.GetCaptainRecord(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get captain record: %w", err)
}
// The PDS returns *atproto.CaptainRecord directly now (after we update pds to use atproto types)
return pdsCaptain, nil
}
// IsCrewMember checks if userDID is a crew member
func (a *LocalHoldAuthorizer) IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) {
// Verify that the requested holdDID matches this hold
if holdDID != a.pds.DID() {
return false, fmt.Errorf("holdDID mismatch: requested %s, this hold is %s", holdDID, a.pds.DID())
}
// Query the PDS for crew list
crewList, err := a.pds.ListCrewMembers(ctx)
if err != nil {
return false, fmt.Errorf("failed to list crew members: %w", err)
}
// Check if userDID is in the crew list
for _, member := range crewList {
if member.Record.Member == userDID {
// TODO: Check expiration if set
return true, nil
}
}
return false, nil
}
// CheckReadAccess implements read authorization using shared logic
func (a *LocalHoldAuthorizer) CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
captain, err := a.GetCaptainRecord(ctx, holdDID)
if err != nil {
return false, err
}
return CheckReadAccessWithCaptain(captain, userDID), nil
}
// CheckWriteAccess implements write authorization using shared logic
func (a *LocalHoldAuthorizer) CheckWriteAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
captain, err := a.GetCaptainRecord(ctx, holdDID)
if err != nil {
return false, err
}
isCrew, err := a.IsCrewMember(ctx, holdDID, userDID)
if err != nil {
return false, err
}
return CheckWriteAccessWithCaptain(captain, userDID, isCrew), nil
}
+559
View File
@@ -0,0 +1,559 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"atcr.io/pkg/atproto"
)
// RemoteHoldAuthorizer queries a hold's PDS via XRPC endpoints
// Used by AppView to authorize access to remote holds
// Implements caching for captain records to reduce XRPC calls
type RemoteHoldAuthorizer struct {
db *sql.DB
httpClient *http.Client
cacheTTL time.Duration // TTL for captain record cache
recentDenials sync.Map // In-memory cache for first denials (10s backoff)
stopCleanup chan struct{} // Signal to stop cleanup goroutine
}
// denialEntry stores timestamp for in-memory first denials
type denialEntry struct {
timestamp time.Time
}
// NewRemoteHoldAuthorizer creates a new remote authorizer for AppView
func NewRemoteHoldAuthorizer(db *sql.DB) HoldAuthorizer {
a := &RemoteHoldAuthorizer{
db: db,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
cacheTTL: 1 * time.Hour, // 1 hour cache TTL
stopCleanup: make(chan struct{}),
}
// Start cleanup goroutine for in-memory denials
go a.cleanupRecentDenials()
return a
}
// cleanupRecentDenials runs every 10s to remove expired first-denial entries
func (a *RemoteHoldAuthorizer) cleanupRecentDenials() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
now := time.Now()
a.recentDenials.Range(func(key, value any) bool {
entry := value.(denialEntry)
// Remove entries older than 15 seconds (10s backoff + 5s grace)
if now.Sub(entry.timestamp) > 15*time.Second {
a.recentDenials.Delete(key)
}
return true
})
case <-a.stopCleanup:
return
}
}
}
// GetCaptainRecord retrieves a captain record with caching
// 1. Check database cache
// 2. If cache miss or expired, query hold's XRPC endpoint
// 3. Update cache
func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
// Try cache first
if a.db != nil {
cached, err := a.getCachedCaptainRecord(holdDID)
if err == nil && cached != nil {
// Cache hit - check if still valid
if time.Since(cached.UpdatedAt) < a.cacheTTL {
return cached.CaptainRecord, nil
}
// Cache expired - continue to fetch fresh data
}
}
// Cache miss or expired - query XRPC endpoint
record, err := a.fetchCaptainRecordFromXRPC(ctx, holdDID)
if err != nil {
return nil, err
}
// Update cache
if a.db != nil {
if err := a.setCachedCaptainRecord(holdDID, record); err != nil {
// Log error but don't fail - caching is best-effort
fmt.Printf("WARNING: Failed to cache captain record: %v\n", err)
}
}
return record, nil
}
// captainRecordWithMeta includes UpdatedAt for cache management
type captainRecordWithMeta struct {
*atproto.CaptainRecord
UpdatedAt time.Time
}
// getCachedCaptainRecord retrieves a captain record from database cache
func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainRecordWithMeta, error) {
query := `
SELECT owner_did, public, allow_all_crew, deployed_at, region, provider, updated_at
FROM hold_captain_records
WHERE hold_did = ?
`
var record atproto.CaptainRecord
var deployedAt, region, provider sql.NullString
var updatedAt time.Time
err := a.db.QueryRow(query, holdDID).Scan(
&record.Owner,
&record.Public,
&record.AllowAllCrew,
&deployedAt,
&region,
&provider,
&updatedAt,
)
if err == sql.ErrNoRows {
return nil, nil // Cache miss
}
if err != nil {
return nil, fmt.Errorf("cache query failed: %w", err)
}
// Handle nullable fields
if deployedAt.Valid {
record.DeployedAt = deployedAt.String
}
if region.Valid {
record.Region = region.String
}
if provider.Valid {
record.Provider = provider.String
}
return &captainRecordWithMeta{
CaptainRecord: &record,
UpdatedAt: updatedAt,
}, nil
}
// setCachedCaptainRecord stores a captain record in database cache
func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.CaptainRecord) error {
query := `
INSERT INTO hold_captain_records (
hold_did, owner_did, public, allow_all_crew,
deployed_at, region, provider, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(hold_did) DO UPDATE SET
owner_did = excluded.owner_did,
public = excluded.public,
allow_all_crew = excluded.allow_all_crew,
deployed_at = excluded.deployed_at,
region = excluded.region,
provider = excluded.provider,
updated_at = excluded.updated_at
`
_, err := a.db.Exec(query,
holdDID,
record.Owner,
record.Public,
record.AllowAllCrew,
nullString(record.DeployedAt),
nullString(record.Region),
nullString(record.Provider),
time.Now(),
)
return err
}
// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
// Resolve DID to URL
holdURL, err := resolveDIDToURL(holdDID)
if err != nil {
return nil, fmt.Errorf("failed to resolve hold DID: %w", err)
}
// Build XRPC request URL
// GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection=io.atcr.hold.captain&rkey=self
xrpcURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=self",
holdURL, url.QueryEscape(holdDID), url.QueryEscape(atproto.CaptainCollection))
req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
if err != nil {
return nil, err
}
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("XRPC request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("XRPC request failed: status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var xrpcResp struct {
URI string `json:"uri"`
CID string `json:"cid"`
Value struct {
Type string `json:"$type"`
Owner string `json:"owner"`
Public bool `json:"public"`
AllowAllCrew bool `json:"allowAllCrew"`
DeployedAt string `json:"deployedAt"`
Region string `json:"region,omitempty"`
Provider string `json:"provider,omitempty"`
} `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&xrpcResp); err != nil {
return nil, fmt.Errorf("failed to decode XRPC response: %w", err)
}
// Convert to our type
record := &atproto.CaptainRecord{
Type: atproto.CaptainCollection,
Owner: xrpcResp.Value.Owner,
Public: xrpcResp.Value.Public,
AllowAllCrew: xrpcResp.Value.AllowAllCrew,
DeployedAt: xrpcResp.Value.DeployedAt,
Region: xrpcResp.Value.Region,
Provider: xrpcResp.Value.Provider,
}
return record, nil
}
// IsCrewMember checks if userDID is a crew member with caching
// 1. Check approval cache (15min TTL)
// 2. Check denial cache with exponential backoff
// 3. If cache miss, query XRPC endpoint and update cache
func (a *RemoteHoldAuthorizer) IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) {
// Skip caching if no database
if a.db == nil {
return a.isCrewMemberNoCache(ctx, holdDID, userDID)
}
// Check approval cache first (15min TTL)
if approved, err := a.getCachedApproval(holdDID, userDID); err == nil && approved {
return true, nil
}
// Check denial cache with backoff
if blocked, err := a.isBlockedByDenialBackoff(holdDID, userDID); err == nil && blocked {
// Still in backoff period - don't query again
return false, nil
}
// Cache miss or expired - query XRPC endpoint
isCrew, err := a.isCrewMemberNoCache(ctx, holdDID, userDID)
if err != nil {
return false, err
}
// Update cache based on result
if isCrew {
// Cache approval for 15 minutes
_ = a.cacheApproval(holdDID, userDID, 15*time.Minute)
} else {
// Cache denial with exponential backoff
_ = a.cacheDenial(holdDID, userDID)
}
return isCrew, nil
}
// isCrewMemberNoCache queries XRPC without caching (internal helper)
func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) {
// Resolve DID to URL
holdURL, err := resolveDIDToURL(holdDID)
if err != nil {
return false, fmt.Errorf("failed to resolve hold DID: %w", err)
}
// Build XRPC request URL
// GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection=io.atcr.hold.crew
xrpcURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s",
holdURL, url.QueryEscape(holdDID), url.QueryEscape(atproto.CrewCollection))
req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
if err != nil {
return false, err
}
resp, err := a.httpClient.Do(req)
if err != nil {
return false, fmt.Errorf("XRPC request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return false, fmt.Errorf("XRPC request failed: status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var xrpcResp struct {
Records []struct {
URI string `json:"uri"`
CID string `json:"cid"`
Value struct {
Type string `json:"$type"`
Member string `json:"member"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
AddedAt string `json:"addedAt"`
} `json:"value"`
} `json:"records"`
}
if err := json.NewDecoder(resp.Body).Decode(&xrpcResp); err != nil {
return false, fmt.Errorf("failed to decode XRPC response: %w", err)
}
// Check if userDID is in the crew list
for _, record := range xrpcResp.Records {
if record.Value.Member == userDID {
// TODO: Check expiration if set
return true, nil
}
}
return false, nil
}
// CheckReadAccess implements read authorization using shared logic
func (a *RemoteHoldAuthorizer) CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
captain, err := a.GetCaptainRecord(ctx, holdDID)
if err != nil {
return false, err
}
return CheckReadAccessWithCaptain(captain, userDID), nil
}
// CheckWriteAccess implements write authorization using shared logic
func (a *RemoteHoldAuthorizer) CheckWriteAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
captain, err := a.GetCaptainRecord(ctx, holdDID)
if err != nil {
return false, err
}
isCrew, err := a.IsCrewMember(ctx, holdDID, userDID)
if err != nil {
return false, err
}
return CheckWriteAccessWithCaptain(captain, userDID, isCrew), nil
}
// resolveDIDToURL converts a did:web DID to an HTTPS URL
// Example: did:web:hold01.atcr.io → https://hold01.atcr.io
func resolveDIDToURL(did string) (string, error) {
// Handle did:web format
if !strings.HasPrefix(did, "did:web:") {
return "", fmt.Errorf("only did:web is supported, got: %s", did)
}
// Extract hostname from did:web:hostname
hostname := strings.TrimPrefix(did, "did:web:")
// Convert to HTTPS URL
return "https://" + hostname, nil
}
// nullString converts a string to sql.NullString
func nullString(s string) sql.NullString {
if s == "" {
return sql.NullString{Valid: false}
}
return sql.NullString{String: s, Valid: true}
}
// getCachedApproval checks if user has a cached crew approval
func (a *RemoteHoldAuthorizer) getCachedApproval(holdDID, userDID string) (bool, error) {
query := `
SELECT expires_at
FROM hold_crew_approvals
WHERE hold_did = ? AND user_did = ?
`
var expiresAt time.Time
err := a.db.QueryRow(query, holdDID, userDID).Scan(&expiresAt)
if err == sql.ErrNoRows {
return false, nil // Cache miss
}
if err != nil {
return false, err
}
// Check if approval has expired
if time.Now().After(expiresAt) {
// Expired - clean up
_ = a.deleteCachedApproval(holdDID, userDID)
return false, nil
}
return true, nil
}
// cacheApproval stores a crew approval with TTL
func (a *RemoteHoldAuthorizer) cacheApproval(holdDID, userDID string, ttl time.Duration) error {
query := `
INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hold_did, user_did) DO UPDATE SET
approved_at = excluded.approved_at,
expires_at = excluded.expires_at
`
now := time.Now()
expiresAt := now.Add(ttl)
_, err := a.db.Exec(query, holdDID, userDID, now, expiresAt)
return err
}
// deleteCachedApproval removes an expired approval
func (a *RemoteHoldAuthorizer) deleteCachedApproval(holdDID, userDID string) error {
query := `DELETE FROM hold_crew_approvals WHERE hold_did = ? AND user_did = ?`
_, err := a.db.Exec(query, holdDID, userDID)
return err
}
// isBlockedByDenialBackoff checks if user is in denial backoff period
// Checks in-memory cache first (for 10s first denials), then DB (for longer backoffs)
func (a *RemoteHoldAuthorizer) isBlockedByDenialBackoff(holdDID, userDID string) (bool, error) {
// Check in-memory cache first (first denials with 10s backoff)
key := fmt.Sprintf("%s:%s", holdDID, userDID)
if val, ok := a.recentDenials.Load(key); ok {
entry := val.(denialEntry)
// Check if still within 10s backoff
if time.Since(entry.timestamp) < 10*time.Second {
return true, nil // Still blocked by in-memory first denial
}
}
// Check database for longer backoffs (second+ denials)
query := `
SELECT next_retry_at
FROM hold_crew_denials
WHERE hold_did = ? AND user_did = ?
`
var nextRetryAt time.Time
err := a.db.QueryRow(query, holdDID, userDID).Scan(&nextRetryAt)
if err == sql.ErrNoRows {
return false, nil // No denial record
}
if err != nil {
return false, err
}
// Check if still in backoff period
if time.Now().Before(nextRetryAt) {
return true, nil // Still blocked
}
// Backoff period expired - can retry
return false, nil
}
// cacheDenial stores or updates a denial with exponential backoff
// First denial: in-memory only (10s backoff)
// Second+ denial: database with exponential backoff (1m, 5m, 15m, 1h)
func (a *RemoteHoldAuthorizer) cacheDenial(holdDID, userDID string) error {
key := fmt.Sprintf("%s:%s", holdDID, userDID)
// Check if this is a first denial (not in memory, not in DB)
_, inMemory := a.recentDenials.Load(key)
var denialCount int
query := `SELECT denial_count FROM hold_crew_denials WHERE hold_did = ? AND user_did = ?`
err := a.db.QueryRow(query, holdDID, userDID).Scan(&denialCount)
inDB := err != sql.ErrNoRows
if err != nil && err != sql.ErrNoRows {
return err
}
// If not in memory and not in DB, this is the first denial
if !inMemory && !inDB {
// First denial: store only in memory with 10s backoff
a.recentDenials.Store(key, denialEntry{timestamp: time.Now()})
return nil
}
// Second+ denial: persist to database with exponential backoff
denialCount++
backoff := getBackoffDuration(denialCount)
now := time.Now()
nextRetry := now.Add(backoff)
// Upsert denial record
upsertQuery := `
INSERT INTO hold_crew_denials (hold_did, user_did, denial_count, next_retry_at, last_denied_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(hold_did, user_did) DO UPDATE SET
denial_count = excluded.denial_count,
next_retry_at = excluded.next_retry_at,
last_denied_at = excluded.last_denied_at
`
_, err = a.db.Exec(upsertQuery, holdDID, userDID, denialCount, nextRetry, now)
// Remove from in-memory cache since we're now tracking in DB
a.recentDenials.Delete(key)
return err
}
// getBackoffDuration returns the backoff duration based on denial count
// Note: First denial (10s) is in-memory only and not tracked by this function
// This function handles second+ denials: 1m, 5m, 15m, 1h
func getBackoffDuration(denialCount int) time.Duration {
backoffs := []time.Duration{
1 * time.Minute, // 1st DB denial (2nd overall) - being added soon
5 * time.Minute, // 2nd DB denial (3rd overall) - probably not happening
15 * time.Minute, // 3rd DB denial (4th overall) - definitely not soon
60 * time.Minute, // 4th+ DB denial (5th+ overall) - stop hammering
}
idx := denialCount - 1
if idx >= len(backoffs) {
idx = len(backoffs) - 1
}
return backoffs[idx]
}
+12 -10
View File
@@ -27,11 +27,11 @@ type UserStore interface {
// Server handles OAuth authorization for the AppView
type Server struct {
app *App
refresher *Refresher
uiSessionStore UISessionStore
db *sql.DB
defaultHoldEndpoint string
app *App
refresher *Refresher
uiSessionStore UISessionStore
db *sql.DB
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
}
// NewServer creates a new OAuth server
@@ -41,9 +41,11 @@ func NewServer(app *App) *Server {
}
}
// SetDefaultHoldEndpoint sets the default hold endpoint for profile creation
func (s *Server) SetDefaultHoldEndpoint(endpoint string) {
s.defaultHoldEndpoint = endpoint
// SetDefaultHoldDID sets the default hold DID for profile creation
// Expected format: "did:web:hold01.atcr.io"
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
func (s *Server) SetDefaultHoldDID(did string) {
s.defaultHoldDID = did
}
// SetRefresher sets the refresher for invalidating session cache
@@ -271,8 +273,8 @@ func (s *Server) fetchAndStoreAvatar(ctx context.Context, did, sessionID, handle
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
// Ensure sailor profile exists (creates with default hold if configured, or empty profile if not)
fmt.Printf("DEBUG [oauth/server]: Ensuring profile exists for %s (defaultHold=%s)\n", did, s.defaultHoldEndpoint)
if err := atproto.EnsureProfile(ctx, client, s.defaultHoldEndpoint); err != nil {
fmt.Printf("DEBUG [oauth/server]: Ensuring profile exists for %s (defaultHold=%s)\n", did, s.defaultHoldDID)
if err := atproto.EnsureProfile(ctx, client, s.defaultHoldDID); err != nil {
fmt.Printf("WARNING [oauth/server]: Failed to ensure profile for %s: %v\n", did, err)
// Continue anyway - profile creation is not critical for avatar fetch
} else {
+12 -10
View File
@@ -18,19 +18,21 @@ import (
// Handler handles /auth/token requests
type Handler struct {
issuer *Issuer
validator *atproto.SessionValidator
deviceStore *db.DeviceStore // For validating device secrets
defaultHoldEndpoint string
issuer *Issuer
validator *atproto.SessionValidator
deviceStore *db.DeviceStore // For validating device secrets
defaultHoldDID string
}
// NewHandler creates a new token handler
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldEndpoint string) *Handler {
// defaultHoldDID should be in format "did:web:hold01.atcr.io"
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldDID string) *Handler {
return &Handler{
issuer: issuer,
validator: atproto.NewSessionValidator(),
deviceStore: deviceStore,
defaultHoldEndpoint: defaultHoldEndpoint,
issuer: issuer,
validator: atproto.NewSessionValidator(),
deviceStore: deviceStore,
defaultHoldDID: defaultHoldDID,
}
}
@@ -157,7 +159,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
// Ensure profile exists (will create with default hold if not exists and default is configured)
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldDID); err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
}
-181
View File
@@ -1,181 +0,0 @@
package hold
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// isAuthorizedRead checks if a DID can read from this hold
// Authorization:
// - Public hold: allow anonymous (empty DID) or any authenticated user
// - Private hold: require authentication (any user with sailor.profile)
func (s *HoldService) isAuthorizedRead(did string) bool {
// Check hold public flag
isPublic, err := s.isHoldPublic()
if err != nil {
log.Printf("ERROR: Failed to check hold public flag: %v", err)
// Fail secure - deny access on error
return false
}
if isPublic {
// Public hold - allow anyone (even anonymous)
return true
}
// Private hold - require authentication
// Any authenticated user with sailor.profile can read
if did == "" {
// Anonymous user trying to access private hold
return false
}
// For MVP: assume DID presence means they have sailor.profile
// Future: could query PDS to verify sailor.profile exists
return true
}
// isAuthorizedWrite checks if a DID can write to this hold
// Authorization: must be hold owner OR crew member
func (s *HoldService) isAuthorizedWrite(did string) bool {
if did == "" {
// Anonymous writes not allowed
return false
}
// Check if DID is the hold owner
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
log.Printf("ERROR: Hold owner DID not configured")
return false
}
if did == ownerDID {
// Owner always has write access
return true
}
// Check if DID is a crew member
isCrew, err := s.isCrewMember(did)
if err != nil {
log.Printf("ERROR: Failed to check crew membership: %v", err)
return false
}
return isCrew
}
// isHoldPublic checks if this hold allows public (anonymous) reads
func (s *HoldService) isHoldPublic() (bool, error) {
// Use cached config value for now
// Future: could query PDS for hold record to get live value
return s.config.Server.Public, nil
}
// isCrewMember checks if a DID is a crew member of this hold
// Supports both explicit DID matching and pattern-based matching (wildcards, handle globs)
func (s *HoldService) isCrewMember(did string) (bool, error) {
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
return false, fmt.Errorf("hold owner DID not configured")
}
ctx := context.Background()
// Resolve owner's PDS endpoint using indigo
directory := identity.DefaultDirectory()
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
if err != nil {
return false, fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
if err != nil {
return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return false, fmt.Errorf("no PDS endpoint found for owner")
}
// Build this hold's URI for filtering
publicURL := s.config.Server.PublicURL
if publicURL == "" {
return false, fmt.Errorf("hold public URL not configured")
}
holdName, err := extractHostname(publicURL)
if err != nil {
return false, fmt.Errorf("failed to extract hold name: %w", err)
}
holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
// Create unauthenticated client to read public records
client := atproto.NewClient(pdsEndpoint, ownerDID, "")
// List crew records for this hold
// Crew records are public, so we can read them without auth
records, err := client.ListRecords(ctx, atproto.HoldCrewCollection, 100)
if err != nil {
return false, fmt.Errorf("failed to list crew records: %w", err)
}
// Resolve handle once for pattern matching (lazily, only if needed)
var handle string
var handleResolved bool
// Check crew records for both explicit DID and pattern matches
for _, record := range records {
var crewRecord atproto.HoldCrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
continue
}
// Only check crew records for THIS hold (prevents cross-hold access)
if crewRecord.Hold != holdURI {
continue
}
// Check expiration (if set)
if crewRecord.ExpiresAt != nil && time.Now().After(*crewRecord.ExpiresAt) {
continue // Skip expired membership
}
// Check explicit DID match
if crewRecord.Member != nil && *crewRecord.Member == did {
// Found explicit crew membership
return true, nil
}
// Check pattern match (if pattern is set)
if crewRecord.MemberPattern != nil && *crewRecord.MemberPattern != "" {
// Lazy handle resolution - only resolve if we encounter a pattern
if !handleResolved {
handle, err = resolveHandle(did)
if err != nil {
log.Printf("Warning: failed to resolve handle for DID %s: %v", did, err)
// Continue checking explicit DIDs even if handle resolution fails
handleResolved = true // Mark as attempted (don't retry)
handle = "" // Empty handle won't match patterns
} else {
handleResolved = true
}
}
// If we have a handle, check pattern match
if handle != "" && matchPattern(*crewRecord.MemberPattern, handle) {
// Found pattern-based crew membership
return true, nil
}
}
}
return false, nil
}
@@ -1,19 +1,19 @@
package pds
package hold
import (
"context"
"atcr.io/pkg/hold"
"atcr.io/pkg/hold/pds"
)
// HoldServiceBlobStore adapts the hold service to implement the BlobStore interface
// HoldServiceBlobStore adapts the hold service to implement the pds.BlobStore interface
type HoldServiceBlobStore struct {
service *hold.HoldService
service *HoldService
holdDID string
}
// NewHoldServiceBlobStore creates a blob store adapter for the hold service
func NewHoldServiceBlobStore(service *hold.HoldService, holdDID string) *HoldServiceBlobStore {
func NewHoldServiceBlobStore(service *HoldService, holdDID string) pds.BlobStore {
return &HoldServiceBlobStore{
service: service,
holdDID: holdDID,
@@ -23,9 +23,8 @@ func NewHoldServiceBlobStore(service *hold.HoldService, holdDID string) *HoldSer
// GetPresignedDownloadURL returns a presigned URL for downloading a blob
func (b *HoldServiceBlobStore) GetPresignedDownloadURL(digest string) (string, error) {
// Use the hold service's existing presigned URL logic
// We need to expose a wrapper method on HoldService
ctx := context.Background()
url, err := b.service.GetPresignedURL(ctx, hold.OperationGet, digest, b.holdDID)
url, err := b.service.GetPresignedURL(ctx, OperationGet, digest, b.holdDID)
if err != nil {
return "", err
}
@@ -36,7 +35,7 @@ func (b *HoldServiceBlobStore) GetPresignedDownloadURL(digest string) (string, e
func (b *HoldServiceBlobStore) GetPresignedUploadURL(digest string) (string, error) {
// Use the hold service's existing presigned URL logic
ctx := context.Background()
url, err := b.service.GetPresignedURL(ctx, hold.OperationPut, digest, b.holdDID)
url, err := b.service.GetPresignedURL(ctx, OperationPut, digest, b.holdDID)
if err != nil {
return "", err
}
+12 -13
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/repo"
"github.com/ipfs/go-cid"
)
@@ -17,8 +18,8 @@ const (
// CreateCaptainRecord creates the captain record for the hold
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) (cid.Cid, error) {
captainRecord := &CaptainRecord{
Type: CaptainCollection,
captainRecord := &atproto.CaptainRecord{
Type: atproto.CaptainCollection,
Owner: ownerDID,
Public: public,
AllowAllCrew: allowAllCrew,
@@ -26,7 +27,7 @@ func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, publ
}
// Create record in repo with fixed rkey "self"
recordCID, rkey, err := p.repo.CreateRecord(ctx, CaptainCollection, captainRecord)
recordCID, rkey, err := p.repo.CreateRecord(ctx, atproto.CaptainCollection, captainRecord)
if err != nil {
return cid.Undef, fmt.Errorf("failed to create captain record: %w", err)
}
@@ -48,9 +49,8 @@ func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, publ
return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
}
// Create a new session for the next operation
rootStr := root.String()
newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
// Create a new session for the next operation (use revision string, not CID)
newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rev)
if err != nil {
return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
}
@@ -71,8 +71,8 @@ func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, publ
}
// GetCaptainRecord retrieves the captain record
func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *CaptainRecord, error) {
path := fmt.Sprintf("%s/%s", CaptainCollection, CaptainRkey)
func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.CaptainRecord, error) {
path := fmt.Sprintf("%s/%s", atproto.CaptainCollection, CaptainRkey)
// Get the record bytes and decode manually
recordCID, recBytes, err := p.repo.GetRecordBytes(ctx, path)
@@ -81,7 +81,7 @@ func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *CaptainRecord
}
// Decode the CBOR bytes into our CaptainRecord type
var captainRecord CaptainRecord
var captainRecord atproto.CaptainRecord
if err := captainRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
return cid.Undef, nil, fmt.Errorf("failed to decode captain record: %w", err)
}
@@ -102,7 +102,7 @@ func (p *HoldPDS) UpdateCaptainRecord(ctx context.Context, public bool, allowAll
existing.AllowAllCrew = allowAllCrew
// Update record in repo
path := fmt.Sprintf("%s/%s", CaptainCollection, CaptainRkey)
path := fmt.Sprintf("%s/%s", atproto.CaptainCollection, CaptainRkey)
recordCID, err := p.repo.UpdateRecord(ctx, path, existing)
if err != nil {
return cid.Undef, fmt.Errorf("failed to update captain record: %w", err)
@@ -125,9 +125,8 @@ func (p *HoldPDS) UpdateCaptainRecord(ctx context.Context, public bool, allowAll
return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
}
// Create a new session for the next operation
rootStr := root.String()
newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
// Create a new session for the next operation (use revision string, not CID)
newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rev)
if err != nil {
return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
}
+12 -12
View File
@@ -7,14 +7,15 @@ import (
"strings"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/repo"
"github.com/ipfs/go-cid"
)
// AddCrewMember adds a new crew member to the hold and commits to carstore
func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
crewRecord := &CrewRecord{
Type: CrewCollection,
crewRecord := &atproto.CrewRecord{
Type: atproto.CrewCollection,
Member: memberDID,
Role: role,
Permissions: permissions,
@@ -22,7 +23,7 @@ func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, per
}
// Create record in repo (using memberDID as rkey for easy lookup)
recordCID, _, err := p.repo.CreateRecord(ctx, CrewCollection, crewRecord)
recordCID, _, err := p.repo.CreateRecord(ctx, atproto.CrewCollection, crewRecord)
if err != nil {
return cid.Undef, fmt.Errorf("failed to create crew record: %w", err)
}
@@ -44,9 +45,8 @@ func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, per
return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
}
// Create a new session for the next operation (old session is now closed)
rootStr := root.String()
newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
// Create a new session for the next operation (use revision string, not CID)
newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rev)
if err != nil {
return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
}
@@ -65,8 +65,8 @@ func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, per
}
// GetCrewMember retrieves a crew member by their record key
func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *CrewRecord, error) {
path := fmt.Sprintf("%s/%s", CrewCollection, rkey)
func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.CrewRecord, error) {
path := fmt.Sprintf("%s/%s", atproto.CrewCollection, rkey)
// Get the record bytes and decode manually (indigo doesn't know our custom type)
recordCID, recBytes, err := p.repo.GetRecordBytes(ctx, path)
@@ -75,7 +75,7 @@ func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *Cre
}
// Decode the CBOR bytes into our CrewRecord type
var crewRecord CrewRecord
var crewRecord atproto.CrewRecord
if err := crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
return cid.Undef, nil, fmt.Errorf("failed to decode crew record: %w", err)
}
@@ -87,14 +87,14 @@ func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *Cre
type CrewMemberWithKey struct {
Rkey string
Cid cid.Cid
Record *CrewRecord
Record *atproto.CrewRecord
}
// ListCrewMembers returns all crew members with their rkeys
func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, error) {
var crew []*CrewMemberWithKey
err := p.repo.ForEach(ctx, CrewCollection, func(k string, v cid.Cid) error {
err := p.repo.ForEach(ctx, atproto.CrewCollection, func(k string, v cid.Cid) error {
// Extract rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22")
parts := strings.Split(k, "/")
rkey := parts[len(parts)-1]
@@ -127,7 +127,7 @@ func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, er
// RemoveCrewMember removes a crew member
func (p *HoldPDS) RemoveCrewMember(ctx context.Context, rkey string) error {
path := fmt.Sprintf("%s/%s", CrewCollection, rkey)
path := fmt.Sprintf("%s/%s", atproto.CrewCollection, rkey)
err := p.repo.DeleteRecord(ctx, path)
if err != nil {
+28 -11
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
// DIDDocument represents a did:web document
@@ -35,13 +34,22 @@ type Service struct {
// GenerateDIDDocument creates a DID document for a did:web identity
func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
// Extract hostname from public URL
hostname := strings.TrimPrefix(publicURL, "http://")
hostname = strings.TrimPrefix(hostname, "https://")
hostname = strings.Split(hostname, "/")[0] // Remove any path
hostname = strings.Split(hostname, ":")[0] // Remove port for DID
// Parse URL to extract host and port
u, err := url.Parse(publicURL)
if err != nil {
return nil, fmt.Errorf("failed to parse public URL: %w", err)
}
did := fmt.Sprintf("did:web:%s", hostname)
hostname := u.Hostname()
port := u.Port()
// Build host string (include non-standard ports per did:web spec)
host := hostname
if port != "" && port != "80" && port != "443" {
host = fmt.Sprintf("%s:%s", hostname, port)
}
did := fmt.Sprintf("did:web:%s", host)
// Get public key in multibase format using indigo's crypto
pubKey, err := p.signingKey.PublicKey()
@@ -58,7 +66,7 @@ func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
},
ID: did,
AlsoKnownAs: []string{
fmt.Sprintf("at://%s", hostname),
fmt.Sprintf("at://%s", host),
},
VerificationMethod: []VerificationMethod{
{
@@ -99,20 +107,29 @@ func (p *HoldPDS) MarshalDIDDocument() ([]byte, error) {
}
// GenerateDIDFromURL creates a did:web identifier from a public URL
// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com"
// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080"
// Note: Per did:web spec, non-standard ports (not 80/443) are included in the DID
func GenerateDIDFromURL(publicURL string) string {
// Parse URL
u, err := url.Parse(publicURL)
if err != nil {
// Fallback: assume it's just a hostname
return fmt.Sprintf("did:web:%s", strings.Split(publicURL, ":")[0])
return fmt.Sprintf("did:web:%s", publicURL)
}
// Use hostname without port for DID
// Get hostname
hostname := u.Hostname()
if hostname == "" {
hostname = "localhost"
}
// Get port
port := u.Port()
// Include port in DID if it's non-standard (not 80 for http, not 443 for https)
if port != "" && port != "80" && port != "443" {
return fmt.Sprintf("did:web:%s:%s", hostname, port)
}
return fmt.Sprintf("did:web:%s", hostname)
}
+88 -16
View File
@@ -5,7 +5,9 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/carstore"
"github.com/bluesky-social/indigo/models"
@@ -59,17 +61,22 @@ func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string) (*H
var session *carstore.DeltaSession
var r *repo.Repo
// Create a session connected to this user's data in carstore
session, err = cs.NewDeltaSession(ctx, uid, nil)
if err != nil {
return nil, fmt.Errorf("failed to create delta session: %w", err)
}
if !hasValidRepo {
// No valid repo - create new empty repo
// No valid repo - create new session with nil (new repo)
session, err = cs.NewDeltaSession(ctx, uid, nil)
if err != nil {
return nil, fmt.Errorf("failed to create delta session: %w", err)
}
// Create new empty repo
r = repo.NewRepo(ctx, did, session)
} else {
// Repo exists with valid head - load from existing head
// Repo exists with valid head - create session pointing to current head
headStr := head.String()
session, err = cs.NewDeltaSession(ctx, uid, &headStr)
if err != nil {
return nil, fmt.Errorf("failed to create delta session: %w", err)
}
// Load from existing head
r, err = repo.OpenRepo(ctx, session, head)
if err != nil {
return nil, fmt.Errorf("failed to open existing repo: %w", err)
@@ -104,17 +111,27 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, a
return nil
}
// Check if repo already has commits
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
if err != nil || !head.Defined() {
// No repo exists yet, bootstrap
fmt.Printf("🚀 Bootstrapping hold PDS with owner: %s\n", ownerDID)
} else {
// Repo exists and is valid
fmt.Printf("⏭️ Skipping PDS bootstrap: repo already initialized (head: %s)\n", head.String()[:16])
// Check if captain record already exists (idempotent bootstrap)
_, _, err := p.GetCaptainRecord(ctx)
if err == nil {
// Captain record exists, we're good
fmt.Printf("✅ Captain record exists, skipping bootstrap\n")
return nil
}
// No captain record - check if this is a new repo or existing repo
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
isNewRepo := (err != nil || !head.Defined())
if isNewRepo {
fmt.Printf("🚀 Bootstrapping new hold PDS with owner: %s\n", ownerDID)
// For new repo, create records inline to avoid session issues
return p.bootstrapNewRepo(ctx, ownerDID, public, allowAllCrew)
}
// Existing repo - use normal record creation flow
fmt.Printf("️ Repo already initialized (head: %s), creating captain record...\n", head.String()[:16])
// Create captain record (hold ownership and settings)
_, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew)
if err != nil {
@@ -133,6 +150,61 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, a
return nil
}
// bootstrapNewRepo handles bootstrapping a brand new repo (avoids session juggling issues)
func (p *HoldPDS) bootstrapNewRepo(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) error {
// Create captain and crew records in a single commit
captainRecord := &atproto.CaptainRecord{
Type: atproto.CaptainCollection,
Owner: ownerDID,
Public: public,
AllowAllCrew: allowAllCrew,
DeployedAt: time.Now().Format(time.RFC3339),
}
crewRecord := &atproto.CrewRecord{
Type: atproto.CrewCollection,
Member: ownerDID,
Role: "admin",
Permissions: []string{"blob:read", "blob:write", "crew:admin"},
AddedAt: time.Now().Format(time.RFC3339),
}
// Create both records in the repo
_, _, err := p.repo.CreateRecord(ctx, atproto.CaptainCollection, captainRecord)
if err != nil {
return fmt.Errorf("failed to create captain record: %w", err)
}
_, _, err = p.repo.CreateRecord(ctx, atproto.CrewCollection, crewRecord)
if err != nil {
return fmt.Errorf("failed to create crew record: %w", err)
}
// Commit everything in one go
signer := func(ctx context.Context, did string, data []byte) ([]byte, error) {
return p.signingKey.HashAndSign(data)
}
root, rev, err := p.repo.Commit(ctx, signer)
if err != nil {
return fmt.Errorf("failed to commit bootstrap records: %w", err)
}
// Close the session with the new root
_, err = p.session.CloseWithRoot(ctx, root, rev)
if err != nil {
return fmt.Errorf("failed to persist bootstrap commit: %w", err)
}
fmt.Printf("✅ Created captain record (public=%v, allowAllCrew=%v)\n", public, allowAllCrew)
fmt.Printf("✅ Added %s as hold admin\n", ownerDID)
// DON'T create a new session here - let subsequent operations handle that
// The PDS is now bootstrapped and will be reloaded properly on next restart
return nil
}
// Close closes the session and carstore
func (p *HoldPDS) Close() error {
// TODO: Close session properly
-32
View File
@@ -1,32 +0,0 @@
package pds
//go:generate go run github.com/whyrusleeping/cbor-gen --map-encoding CrewRecord CaptainRecord
// ATProto record types for the hold service
// CaptainRecord represents the hold's ownership and metadata
// Collection: io.atcr.hold.captain (single record per hold)
type CaptainRecord struct {
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
}
// CrewRecord represents a crew member in the hold
// Collection: io.atcr.hold.crew (one record per member)
type CrewRecord struct {
Type string `json:"$type" cborgen:"$type"`
Member string `json:"member" cborgen:"member"`
Role string `json:"role" cborgen:"role"`
Permissions []string `json:"permissions" cborgen:"permissions"`
AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
}
const (
CaptainCollection = "io.atcr.hold.captain"
CrewCollection = "io.atcr.hold.crew"
)
+6 -5
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/repo"
"github.com/bluesky-social/indigo/util"
"github.com/ipfs/go-cid"
@@ -151,7 +152,7 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request)
"did": h.pds.DID(),
"handle": h.pds.DID(),
"didDoc": didDoc,
"collections": []string{CrewCollection},
"collections": []string{atproto.CrewCollection},
"handleIsCorrect": true,
}
@@ -181,7 +182,7 @@ func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) {
}
// Only support crew collection for now
if collection != CrewCollection {
if collection != atproto.CrewCollection {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
@@ -223,7 +224,7 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request)
}
// Only support crew collection for now
if collection != CrewCollection {
if collection != atproto.CrewCollection {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
@@ -273,7 +274,7 @@ func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request
}
// Only support crew collection for now
if collection != CrewCollection {
if collection != atproto.CrewCollection {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
@@ -551,7 +552,7 @@ func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request)
if member.Record.Member == user.DID {
// Already a crew member, return success with existing record
response := map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), CrewCollection, member.Rkey),
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey),
"cid": member.Cid.String(),
"status": "already_member",
"message": "User is already a crew member",
+51 -5
View File
@@ -7,23 +7,33 @@ import (
"net/http"
"net/url"
"atcr.io/pkg/auth"
"github.com/aws/aws-sdk-go/service/s3"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
)
// HoldPDSInterface is the minimal interface needed from the embedded PDS
// This avoids a circular import between pkg/hold and pkg/hold/pds
type HoldPDSInterface interface {
DID() string
}
// HoldService provides presigned URLs for blob storage in a hold
type HoldService struct {
driver storagedriver.StorageDriver
config *Config
s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
bucket string // S3 bucket name
s3PathPrefix string // S3 path prefix (if any)
MultipartMgr *MultipartManager // Exported for access in route handlers
s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
bucket string // S3 bucket name
s3PathPrefix string // S3 path prefix (if any)
MultipartMgr *MultipartManager // Exported for access in route handlers
pds HoldPDSInterface // Embedded PDS for captain/crew records
authorizer auth.HoldAuthorizer // Authorizer for access control
}
// NewHoldService creates a new hold service
func NewHoldService(cfg *Config) (*HoldService, error) {
// holdPDS must be a *pds.HoldPDS but we use any to avoid import cycle
func NewHoldService(cfg *Config, holdPDS any) (*HoldService, error) {
// Create storage driver from config
ctx := context.Background()
driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
@@ -31,10 +41,22 @@ func NewHoldService(cfg *Config) (*HoldService, error) {
return nil, fmt.Errorf("failed to create storage driver: %w", err)
}
// Create local authorizer using the embedded PDS
// This requires casting holdPDS to the concrete type expected by auth
authorizer := auth.NewLocalHoldAuthorizerFromInterface(holdPDS)
// Cast to our interface for storage
pdsInterface, ok := holdPDS.(HoldPDSInterface)
if !ok {
return nil, fmt.Errorf("holdPDS must implement HoldPDSInterface")
}
service := &HoldService{
driver: driver,
config: cfg,
MultipartMgr: NewMultipartManager(),
pds: pdsInterface,
authorizer: authorizer,
}
// Initialize S3 client for presigned URLs (if using S3 storage)
@@ -50,6 +72,30 @@ func (s *HoldService) GetPresignedURL(ctx context.Context, operation PresignedUR
return s.getPresignedURL(ctx, operation, digest, did)
}
// isAuthorizedRead checks if the given DID has read access to this hold
// This is a helper wrapper around the authorizer for internal use
func (s *HoldService) isAuthorizedRead(did string) bool {
ctx := context.Background()
allowed, err := s.authorizer.CheckReadAccess(ctx, s.pds.DID(), did)
if err != nil {
log.Printf("Authorization check failed: %v", err)
return false
}
return allowed
}
// isAuthorizedWrite checks if the given DID has write access to this hold
// This is a helper wrapper around the authorizer for internal use
func (s *HoldService) isAuthorizedWrite(did string) bool {
ctx := context.Background()
allowed, err := s.authorizer.CheckWriteAccess(ctx, s.pds.DID(), did)
if err != nil {
log.Printf("Authorization check failed: %v", err)
return false
}
return allowed
}
// HealthHandler handles health check requests
func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")