mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 04:37:06 +00:00
crazy refactor to start using holds embedded pds for crew/captain validation
This commit is contained in:
+21
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
®ion,
|
||||
&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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user