A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

try and implement getsession and app-password

+1318 -23
+5
cmd/hold/main.go
··· 100 100 // Add logging middleware to log all HTTP requests 101 101 r.Use(middleware.Logger) 102 102 103 + // Add CORS middleware (must be before routes) 104 + if xrpcHandler != nil { 105 + r.Use(xrpcHandler.CORSMiddleware()) 106 + } 107 + 103 108 // Root page 104 109 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 105 110 w.Header().Set("Content-Type", "text/plain")
+1
go.mod
··· 46 46 github.com/docker/go-metrics v0.0.1 // indirect 47 47 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 48 48 github.com/felixge/httpsnoop v1.0.4 // indirect 49 + github.com/go-chi/cors v1.2.2 // indirect 49 50 github.com/go-jose/go-jose/v4 v4.1.2 // indirect 50 51 github.com/go-logr/logr v1.4.2 // indirect 51 52 github.com/go-logr/stdr v1.2.2 // indirect
+2
go.sum
··· 68 68 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 69 69 github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= 70 70 github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 71 + github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= 72 + github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 71 73 github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= 72 74 github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= 73 75 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+23
pkg/atproto/endpoints.go
··· 120 120 // Response: {"accessJwt": "...", "refreshJwt": "...", "did": "...", "handle": "..."} 121 121 ServerCreateSession = "/xrpc/com.atproto.server.createSession" 122 122 123 + // ServerRefreshSession refreshes an existing session using a refresh token. 124 + // Method: POST 125 + // Headers: Authorization (Bearer <refreshJwt>) 126 + // Response: {"accessJwt": "...", "refreshJwt": "...", "did": "...", "handle": "..."} 127 + ServerRefreshSession = "/xrpc/com.atproto.server.refreshSession" 128 + 123 129 // ServerGetSession validates a session and returns the current session info. 124 130 // Method: GET 125 131 // Headers: Authorization (Bearer or DPoP), DPoP (if using DPoP) ··· 179 185 // Response: {"did": "did:plc:..."} 180 186 IdentityResolveHandle = "/xrpc/com.atproto.identity.resolveHandle" 181 187 ) 188 + 189 + // Bluesky app endpoints (app.bsky.actor.*) 190 + // 191 + // Bluesky-specific actor/profile endpoints. 192 + const ( 193 + // ActorGetProfile retrieves an aggregated profile for an actor. 194 + // Method: GET 195 + // Query: actor={did|handle} 196 + // Response: {"did": "...", "handle": "...", "displayName": "...", "postsCount": ...} 197 + ActorGetProfile = "/xrpc/app.bsky.actor.getProfile" 198 + 199 + // ActorGetProfiles retrieves aggregated profiles for multiple actors. 200 + // Method: GET 201 + // Query: actors={did|handle}&actors={did|handle}... 202 + // Response: {"profiles": [{...}, {...}]} 203 + ActorGetProfiles = "/xrpc/app.bsky.actor.getProfiles" 204 + )
+138
pkg/hold/pds/apppassword.go
··· 1 + package pds 2 + 3 + import ( 4 + "crypto/rand" 5 + "encoding/base32" 6 + "fmt" 7 + "strings" 8 + 9 + "golang.org/x/crypto/bcrypt" 10 + ) 11 + 12 + // GenerateAppPassword creates a random app password in the format: abcd-efgh-ijkl-mnop 13 + // Uses base32 encoding for readable characters (no ambiguous chars like 0/O, 1/l) 14 + func GenerateAppPassword() (string, error) { 15 + // Generate 20 random bytes (160 bits of entropy) 16 + // Base32 encoding gives us 32 characters, we'll format as 4 groups of 4 17 + randomBytes := make([]byte, 20) 18 + if _, err := rand.Read(randomBytes); err != nil { 19 + return "", fmt.Errorf("failed to generate random bytes: %w", err) 20 + } 21 + 22 + // Encode as base32 and lowercase (base32 alphabet: a-z, 2-7) 23 + encoded := base32.StdEncoding.EncodeToString(randomBytes) 24 + encoded = strings.ToLower(encoded) 25 + 26 + // Remove padding and take first 16 characters 27 + encoded = strings.TrimRight(encoded, "=") 28 + if len(encoded) > 16 { 29 + encoded = encoded[:16] 30 + } 31 + 32 + // Format as: xxxx-xxxx-xxxx-xxxx 33 + parts := []string{ 34 + encoded[0:4], 35 + encoded[4:8], 36 + encoded[8:12], 37 + encoded[12:16], 38 + } 39 + 40 + return strings.Join(parts, "-"), nil 41 + } 42 + 43 + // HashAppPassword hashes an app password using bcrypt 44 + // Cost is set to 12 for good security without excessive CPU usage 45 + func HashAppPassword(password string) (string, error) { 46 + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) 47 + if err != nil { 48 + return "", fmt.Errorf("failed to hash password: %w", err) 49 + } 50 + return string(hash), nil 51 + } 52 + 53 + // ValidateAppPassword compares a plaintext password with a bcrypt hash 54 + func ValidateAppPassword(password, hash string) bool { 55 + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 56 + return err == nil 57 + } 58 + 59 + // CreateAppPassword generates and stores a new app password 60 + func (p *HoldPDS) CreateAppPassword(name string) (string, error) { 61 + // Generate random password 62 + password, err := GenerateAppPassword() 63 + if err != nil { 64 + return "", fmt.Errorf("failed to generate password: %w", err) 65 + } 66 + 67 + // Hash password 68 + hash, err := HashAppPassword(password) 69 + if err != nil { 70 + return "", fmt.Errorf("failed to hash password: %w", err) 71 + } 72 + 73 + // Store in database 74 + if err := p.authDB.CreateAppPassword(name, hash); err != nil { 75 + return "", fmt.Errorf("failed to store password: %w", err) 76 + } 77 + 78 + return password, nil 79 + } 80 + 81 + // ValidateAppPasswordByName checks if a password matches the stored hash for a given name 82 + func (p *HoldPDS) ValidateAppPasswordByName(name, password string) error { 83 + // Get app password from database 84 + ap, err := p.authDB.GetAppPassword(name) 85 + if err != nil { 86 + return fmt.Errorf("app password not found: %w", err) 87 + } 88 + 89 + // Validate password 90 + if !ValidateAppPassword(password, ap.PasswordHash) { 91 + return fmt.Errorf("invalid password") 92 + } 93 + 94 + // Update last used timestamp 95 + if err := p.authDB.UpdateLastUsed(name); err != nil { 96 + // Log but don't fail - this is not critical 97 + fmt.Printf("Warning: failed to update last used timestamp: %v\n", err) 98 + } 99 + 100 + return nil 101 + } 102 + 103 + // ValidateAnyAppPassword checks if a password matches any stored app password 104 + // Returns the name of the matching app password, or error if none match 105 + func (p *HoldPDS) ValidateAnyAppPassword(password string) (string, error) { 106 + // List all app passwords 107 + passwords, err := p.authDB.ListAppPasswords() 108 + if err != nil { 109 + return "", fmt.Errorf("failed to list app passwords: %w", err) 110 + } 111 + 112 + // Try each one 113 + for _, ap := range passwords { 114 + // Get full record with hash 115 + fullAP, err := p.authDB.GetAppPassword(ap.Name) 116 + if err != nil { 117 + continue 118 + } 119 + 120 + if ValidateAppPassword(password, fullAP.PasswordHash) { 121 + // Update last used 122 + p.authDB.UpdateLastUsed(ap.Name) 123 + return ap.Name, nil 124 + } 125 + } 126 + 127 + return "", fmt.Errorf("invalid app password") 128 + } 129 + 130 + // ListAppPasswords returns a list of app password names (without hashes) 131 + func (p *HoldPDS) ListAppPasswords() ([]AppPassword, error) { 132 + return p.authDB.ListAppPasswords() 133 + } 134 + 135 + // RevokeAppPassword deletes an app password 136 + func (p *HoldPDS) RevokeAppPassword(name string) error { 137 + return p.authDB.DeleteAppPassword(name) 138 + }
+30
pkg/hold/pds/auth.go
··· 528 528 529 529 return publicKey, nil 530 530 } 531 + 532 + // ValidateJWTAuth validates a request with a JWT access token from createSession 533 + // This is used for authenticated repo operations (createRecord, etc.) 534 + // Returns the validated user DID 535 + func ValidateJWTAuth(r *http.Request, pds *HoldPDS) (*ValidatedUser, error) { 536 + // Extract Authorization header 537 + authHeader := r.Header.Get("Authorization") 538 + if authHeader == "" { 539 + return nil, fmt.Errorf("missing Authorization header") 540 + } 541 + 542 + // Remove "Bearer " prefix 543 + accessToken := strings.TrimPrefix(authHeader, "Bearer ") 544 + if accessToken == authHeader { 545 + return nil, fmt.Errorf("invalid authorization header format (expected Bearer)") 546 + } 547 + 548 + // Validate access token 549 + claims, err := pds.ValidateAccessToken(accessToken) 550 + if err != nil { 551 + return nil, fmt.Errorf("invalid access token: %w", err) 552 + } 553 + 554 + return &ValidatedUser{ 555 + DID: claims.DID, 556 + Handle: claims.Handle, 557 + PDS: "", 558 + Authorized: true, 559 + }, nil 560 + }
+232
pkg/hold/pds/database.go
··· 1 + package pds 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "time" 9 + 10 + _ "github.com/mattn/go-sqlite3" 11 + ) 12 + 13 + // Database manages app passwords and sessions for the hold PDS 14 + type Database struct { 15 + db *sql.DB 16 + } 17 + 18 + // NewDatabase creates or opens a database for app passwords and sessions 19 + // dbPath should be the directory path (same as carstore) 20 + // It creates a separate "auth.db" file for authentication data 21 + func NewDatabase(dbPath string) (*Database, error) { 22 + // Ensure directory exists 23 + if err := os.MkdirAll(dbPath, 0755); err != nil { 24 + return nil, fmt.Errorf("failed to create database directory: %w", err) 25 + } 26 + 27 + // Create auth database file alongside carstore database 28 + authDBFile := filepath.Join(dbPath, "auth.db") 29 + 30 + db, err := sql.Open("sqlite3", authDBFile) 31 + if err != nil { 32 + return nil, fmt.Errorf("failed to open database: %w", err) 33 + } 34 + 35 + // Create tables 36 + if err := createTables(db); err != nil { 37 + db.Close() 38 + return nil, fmt.Errorf("failed to create tables: %w", err) 39 + } 40 + 41 + return &Database{db: db}, nil 42 + } 43 + 44 + // createTables creates the database schema 45 + func createTables(db *sql.DB) error { 46 + schema := ` 47 + CREATE TABLE IF NOT EXISTS app_passwords ( 48 + id INTEGER PRIMARY KEY AUTOINCREMENT, 49 + name TEXT NOT NULL UNIQUE, 50 + password_hash TEXT NOT NULL, 51 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 52 + last_used_at TIMESTAMP 53 + ); 54 + 55 + CREATE INDEX IF NOT EXISTS idx_app_passwords_name ON app_passwords(name); 56 + 57 + CREATE TABLE IF NOT EXISTS refresh_tokens ( 58 + id INTEGER PRIMARY KEY AUTOINCREMENT, 59 + token_hash TEXT NOT NULL UNIQUE, 60 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 61 + expires_at TIMESTAMP NOT NULL, 62 + last_used_at TIMESTAMP 63 + ); 64 + 65 + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); 66 + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at); 67 + ` 68 + 69 + _, err := db.Exec(schema) 70 + return err 71 + } 72 + 73 + // Close closes the database connection 74 + func (d *Database) Close() error { 75 + return d.db.Close() 76 + } 77 + 78 + // AppPassword represents an app password record 79 + type AppPassword struct { 80 + ID int64 81 + Name string 82 + PasswordHash string 83 + CreatedAt time.Time 84 + LastUsedAt *time.Time 85 + } 86 + 87 + // CreateAppPassword stores a new app password 88 + func (d *Database) CreateAppPassword(name, passwordHash string) error { 89 + query := `INSERT INTO app_passwords (name, password_hash) VALUES (?, ?)` 90 + _, err := d.db.Exec(query, name, passwordHash) 91 + if err != nil { 92 + return fmt.Errorf("failed to create app password: %w", err) 93 + } 94 + return nil 95 + } 96 + 97 + // GetAppPassword retrieves an app password by name 98 + func (d *Database) GetAppPassword(name string) (*AppPassword, error) { 99 + query := `SELECT id, name, password_hash, created_at, last_used_at FROM app_passwords WHERE name = ?` 100 + 101 + var ap AppPassword 102 + var lastUsedAt sql.NullTime 103 + 104 + err := d.db.QueryRow(query, name).Scan( 105 + &ap.ID, 106 + &ap.Name, 107 + &ap.PasswordHash, 108 + &ap.CreatedAt, 109 + &lastUsedAt, 110 + ) 111 + 112 + if err == sql.ErrNoRows { 113 + return nil, fmt.Errorf("app password not found") 114 + } 115 + if err != nil { 116 + return nil, fmt.Errorf("failed to get app password: %w", err) 117 + } 118 + 119 + if lastUsedAt.Valid { 120 + ap.LastUsedAt = &lastUsedAt.Time 121 + } 122 + 123 + return &ap, nil 124 + } 125 + 126 + // ListAppPasswords returns all app passwords (without hashes) 127 + func (d *Database) ListAppPasswords() ([]AppPassword, error) { 128 + query := `SELECT id, name, created_at, last_used_at FROM app_passwords ORDER BY created_at DESC` 129 + 130 + rows, err := d.db.Query(query) 131 + if err != nil { 132 + return nil, fmt.Errorf("failed to list app passwords: %w", err) 133 + } 134 + defer rows.Close() 135 + 136 + var passwords []AppPassword 137 + for rows.Next() { 138 + var ap AppPassword 139 + var lastUsedAt sql.NullTime 140 + 141 + if err := rows.Scan(&ap.ID, &ap.Name, &ap.CreatedAt, &lastUsedAt); err != nil { 142 + return nil, fmt.Errorf("failed to scan row: %w", err) 143 + } 144 + 145 + if lastUsedAt.Valid { 146 + ap.LastUsedAt = &lastUsedAt.Time 147 + } 148 + 149 + passwords = append(passwords, ap) 150 + } 151 + 152 + return passwords, rows.Err() 153 + } 154 + 155 + // UpdateLastUsed updates the last used timestamp for an app password 156 + func (d *Database) UpdateLastUsed(name string) error { 157 + query := `UPDATE app_passwords SET last_used_at = CURRENT_TIMESTAMP WHERE name = ?` 158 + _, err := d.db.Exec(query, name) 159 + return err 160 + } 161 + 162 + // DeleteAppPassword removes an app password 163 + func (d *Database) DeleteAppPassword(name string) error { 164 + query := `DELETE FROM app_passwords WHERE name = ?` 165 + result, err := d.db.Exec(query, name) 166 + if err != nil { 167 + return fmt.Errorf("failed to delete app password: %w", err) 168 + } 169 + 170 + rows, err := result.RowsAffected() 171 + if err != nil { 172 + return fmt.Errorf("failed to check rows affected: %w", err) 173 + } 174 + 175 + if rows == 0 { 176 + return fmt.Errorf("app password not found") 177 + } 178 + 179 + return nil 180 + } 181 + 182 + // CreateRefreshToken stores a refresh token 183 + func (d *Database) CreateRefreshToken(tokenHash string, expiresAt time.Time) error { 184 + query := `INSERT INTO refresh_tokens (token_hash, expires_at) VALUES (?, ?)` 185 + _, err := d.db.Exec(query, tokenHash, expiresAt) 186 + if err != nil { 187 + return fmt.Errorf("failed to create refresh token: %w", err) 188 + } 189 + return nil 190 + } 191 + 192 + // ValidateRefreshToken checks if a refresh token exists and is not expired 193 + func (d *Database) ValidateRefreshToken(tokenHash string) (bool, error) { 194 + query := `SELECT expires_at FROM refresh_tokens WHERE token_hash = ?` 195 + 196 + var expiresAt time.Time 197 + err := d.db.QueryRow(query, tokenHash).Scan(&expiresAt) 198 + 199 + if err == sql.ErrNoRows { 200 + return false, nil 201 + } 202 + if err != nil { 203 + return false, fmt.Errorf("failed to validate refresh token: %w", err) 204 + } 205 + 206 + // Check if expired 207 + if time.Now().After(expiresAt) { 208 + // Delete expired token 209 + d.DeleteRefreshToken(tokenHash) 210 + return false, nil 211 + } 212 + 213 + // Update last used 214 + updateQuery := `UPDATE refresh_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?` 215 + d.db.Exec(updateQuery, tokenHash) 216 + 217 + return true, nil 218 + } 219 + 220 + // DeleteRefreshToken removes a refresh token 221 + func (d *Database) DeleteRefreshToken(tokenHash string) error { 222 + query := `DELETE FROM refresh_tokens WHERE token_hash = ?` 223 + _, err := d.db.Exec(query, tokenHash) 224 + return err 225 + } 226 + 227 + // CleanupExpiredTokens removes all expired refresh tokens 228 + func (d *Database) CleanupExpiredTokens() error { 229 + query := `DELETE FROM refresh_tokens WHERE expires_at < CURRENT_TIMESTAMP` 230 + _, err := d.db.Exec(query) 231 + return err 232 + }
+249
pkg/hold/pds/jwt.go
··· 1 + package pds 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "encoding/hex" 7 + "encoding/json" 8 + "fmt" 9 + "time" 10 + ) 11 + 12 + // Session token types 13 + const ( 14 + TokenTypeAccess = "access" 15 + TokenTypeRefresh = "refresh" 16 + ) 17 + 18 + // Token expiration durations 19 + const ( 20 + AccessTokenDuration = 2 * time.Hour // Short-lived access token 21 + RefreshTokenDuration = 90 * 24 * time.Hour // Long-lived refresh token (90 days) 22 + ) 23 + 24 + // SessionClaims represents JWT claims for ATProto sessions 25 + type SessionClaims struct { 26 + DID string `json:"sub"` // Subject (DID) 27 + Issuer string `json:"iss"` // Issuer (PDS DID) 28 + Handle string `json:"handle,omitempty"` 29 + Scope string `json:"scope"` 30 + TokenType string `json:"token_type"` 31 + IssuedAt int64 `json:"iat"` // Unix timestamp 32 + ExpiresAt int64 `json:"exp"` // Unix timestamp 33 + } 34 + 35 + // IssueAccessToken creates a new access JWT for a session 36 + func (p *HoldPDS) IssueAccessToken(did, handle string) (string, error) { 37 + now := time.Now() 38 + claims := &SessionClaims{ 39 + DID: did, 40 + Issuer: p.did, 41 + Handle: handle, 42 + Scope: "com.atproto.access", 43 + TokenType: TokenTypeAccess, 44 + IssuedAt: now.Unix(), 45 + ExpiresAt: now.Add(AccessTokenDuration).Unix(), 46 + } 47 + 48 + return p.signJWT(claims) 49 + } 50 + 51 + // IssueRefreshToken creates a new refresh JWT for a session 52 + func (p *HoldPDS) IssueRefreshToken(did, handle string) (string, error) { 53 + now := time.Now() 54 + claims := &SessionClaims{ 55 + DID: did, 56 + Issuer: p.did, 57 + Handle: handle, 58 + Scope: "com.atproto.refresh", 59 + TokenType: TokenTypeRefresh, 60 + IssuedAt: now.Unix(), 61 + ExpiresAt: now.Add(RefreshTokenDuration).Unix(), 62 + } 63 + 64 + signedToken, err := p.signJWT(claims) 65 + if err != nil { 66 + return "", err 67 + } 68 + 69 + // Store refresh token hash in database for validation/revocation 70 + tokenHash := hashToken(signedToken) 71 + expiresAt := now.Add(RefreshTokenDuration) 72 + if err := p.authDB.CreateRefreshToken(tokenHash, expiresAt); err != nil { 73 + return "", fmt.Errorf("failed to store refresh token: %w", err) 74 + } 75 + 76 + return signedToken, nil 77 + } 78 + 79 + // ValidateAccessToken validates an access JWT and returns the claims 80 + func (p *HoldPDS) ValidateAccessToken(tokenString string) (*SessionClaims, error) { 81 + return p.validateToken(tokenString, TokenTypeAccess) 82 + } 83 + 84 + // ValidateRefreshToken validates a refresh JWT and returns the claims 85 + // Also checks the database to ensure the token hasn't been revoked 86 + func (p *HoldPDS) ValidateRefreshToken(tokenString string) (*SessionClaims, error) { 87 + // First validate signature and claims 88 + claims, err := p.validateToken(tokenString, TokenTypeRefresh) 89 + if err != nil { 90 + return nil, err 91 + } 92 + 93 + // Check if token is in database (not revoked) 94 + tokenHash := hashToken(tokenString) 95 + valid, err := p.authDB.ValidateRefreshToken(tokenHash) 96 + if err != nil { 97 + return nil, fmt.Errorf("failed to validate refresh token in database: %w", err) 98 + } 99 + if !valid { 100 + return nil, fmt.Errorf("refresh token has been revoked or expired") 101 + } 102 + 103 + return claims, nil 104 + } 105 + 106 + // validateToken validates a JWT token and returns the claims 107 + func (p *HoldPDS) validateToken(tokenString, expectedType string) (*SessionClaims, error) { 108 + // Split token into parts 109 + parts := splitJWT(tokenString) 110 + if len(parts) != 3 { 111 + return nil, fmt.Errorf("invalid JWT format") 112 + } 113 + 114 + // Decode header 115 + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) 116 + if err != nil { 117 + return nil, fmt.Errorf("failed to decode header: %w", err) 118 + } 119 + 120 + var header map[string]interface{} 121 + if err := json.Unmarshal(headerBytes, &header); err != nil { 122 + return nil, fmt.Errorf("failed to parse header: %w", err) 123 + } 124 + 125 + // Verify algorithm 126 + alg, ok := header["alg"].(string) 127 + if !ok || alg != "ES256K" { 128 + return nil, fmt.Errorf("unsupported algorithm: %v", alg) 129 + } 130 + 131 + // Decode claims 132 + claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) 133 + if err != nil { 134 + return nil, fmt.Errorf("failed to decode claims: %w", err) 135 + } 136 + 137 + var claims SessionClaims 138 + if err := json.Unmarshal(claimsBytes, &claims); err != nil { 139 + return nil, fmt.Errorf("failed to parse claims: %w", err) 140 + } 141 + 142 + // Verify token type 143 + if claims.TokenType != expectedType { 144 + return nil, fmt.Errorf("invalid token type: expected %s, got %s", expectedType, claims.TokenType) 145 + } 146 + 147 + // Verify issuer 148 + if claims.Issuer != p.did { 149 + return nil, fmt.Errorf("invalid issuer: expected %s, got %s", p.did, claims.Issuer) 150 + } 151 + 152 + // Verify subject matches this hold 153 + if claims.DID != p.did { 154 + return nil, fmt.Errorf("invalid subject: expected %s, got %s", p.did, claims.DID) 155 + } 156 + 157 + // Verify expiration 158 + if time.Now().Unix() > claims.ExpiresAt { 159 + return nil, fmt.Errorf("token has expired") 160 + } 161 + 162 + // Verify signature 163 + signedData := []byte(parts[0] + "." + parts[1]) 164 + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) 165 + if err != nil { 166 + return nil, fmt.Errorf("failed to decode signature: %w", err) 167 + } 168 + 169 + publicKey, err := p.signingKey.PublicKey() 170 + if err != nil { 171 + return nil, fmt.Errorf("failed to get public key: %w", err) 172 + } 173 + 174 + if err := publicKey.HashAndVerify(signedData, signature); err != nil { 175 + return nil, fmt.Errorf("signature verification failed: %w", err) 176 + } 177 + 178 + return &claims, nil 179 + } 180 + 181 + // RevokeRefreshToken revokes a refresh token by removing it from the database 182 + func (p *HoldPDS) RevokeRefreshToken(tokenString string) error { 183 + tokenHash := hashToken(tokenString) 184 + return p.authDB.DeleteRefreshToken(tokenHash) 185 + } 186 + 187 + // hashToken creates a SHA-256 hash of a token for storage 188 + func hashToken(token string) string { 189 + hash := sha256.Sum256([]byte(token)) 190 + return hex.EncodeToString(hash[:]) 191 + } 192 + 193 + // signJWT creates and signs a JWT using the hold's private key 194 + func (p *HoldPDS) signJWT(claims *SessionClaims) (string, error) { 195 + // Create header 196 + header := map[string]interface{}{ 197 + "typ": "JWT", 198 + "alg": "ES256K", 199 + } 200 + 201 + headerJSON, err := json.Marshal(header) 202 + if err != nil { 203 + return "", fmt.Errorf("failed to marshal header: %w", err) 204 + } 205 + 206 + // Create payload 207 + payloadJSON, err := json.Marshal(claims) 208 + if err != nil { 209 + return "", fmt.Errorf("failed to marshal claims: %w", err) 210 + } 211 + 212 + // Base64url encode header and payload 213 + headerEncoded := base64.RawURLEncoding.EncodeToString(headerJSON) 214 + payloadEncoded := base64.RawURLEncoding.EncodeToString(payloadJSON) 215 + 216 + // Create signing input 217 + signingInput := headerEncoded + "." + payloadEncoded 218 + 219 + // Sign with private key 220 + signature, err := p.signingKey.HashAndSign([]byte(signingInput)) 221 + if err != nil { 222 + return "", fmt.Errorf("failed to sign JWT: %w", err) 223 + } 224 + 225 + // Base64url encode signature 226 + signatureEncoded := base64.RawURLEncoding.EncodeToString(signature) 227 + 228 + // Combine into final JWT 229 + jwt := signingInput + "." + signatureEncoded 230 + 231 + return jwt, nil 232 + } 233 + 234 + // splitJWT splits a JWT string into its three parts 235 + func splitJWT(token string) []string { 236 + // JWT format: header.payload.signature 237 + parts := make([]string, 0, 3) 238 + start := 0 239 + for i := 0; i < len(token); i++ { 240 + if token[i] == '.' { 241 + parts = append(parts, token[start:i]) 242 + start = i + 1 243 + } 244 + } 245 + if start < len(token) { 246 + parts = append(parts, token[start:]) 247 + } 248 + return parts 249 + }
+40
pkg/hold/pds/server.go
··· 36 36 dbPath string 37 37 uid models.Uid 38 38 signingKey *atcrypto.PrivateKeyK256 39 + authDB *Database // Authentication database for app passwords and sessions 39 40 } 40 41 41 42 // NewHoldPDS creates or opens a hold PDS with SQLite carstore ··· 83 84 fmt.Printf("New hold repo - will be initialized in Bootstrap\n") 84 85 } 85 86 87 + // Create or open authentication database 88 + authDB, err := NewDatabase(dbPath) 89 + if err != nil { 90 + return nil, fmt.Errorf("failed to create auth database: %w", err) 91 + } 92 + 86 93 return &HoldPDS{ 87 94 did: did, 88 95 PublicURL: publicURL, ··· 91 98 dbPath: dbPath, 92 99 uid: uid, 93 100 signingKey: signingKey, 101 + authDB: authDB, 94 102 }, nil 95 103 } 96 104 ··· 192 200 } else { 193 201 fmt.Printf("✅ Tangled profile record already exists, skipping\n") 194 202 } 203 + } 204 + 205 + // Create bootstrap app password if none exist (one-time setup) 206 + passwords, err := p.authDB.ListAppPasswords() 207 + if err != nil { 208 + return fmt.Errorf("failed to list app passwords: %w", err) 209 + } 210 + 211 + if len(passwords) == 0 { 212 + // No app passwords exist, create one 213 + password, err := p.CreateAppPassword("bootstrap") 214 + if err != nil { 215 + return fmt.Errorf("failed to create bootstrap app password: %w", err) 216 + } 217 + 218 + fmt.Printf("\n") 219 + fmt.Printf("╔════════════════════════════════════════════════════════════════╗\n") 220 + fmt.Printf("║ 🔑 APP PASSWORD CREATED ║\n") 221 + fmt.Printf("╠════════════════════════════════════════════════════════════════╣\n") 222 + fmt.Printf("║ ║\n") 223 + fmt.Printf("║ Password: %-51s ║\n", password) 224 + fmt.Printf("║ ║\n") 225 + fmt.Printf("║ ⚠️ SAVE THIS PASSWORD - it will not be shown again ║\n") 226 + fmt.Printf("║ ║\n") 227 + fmt.Printf("║ Use this password to log into Bluesky app or CLI tools ║\n") 228 + fmt.Printf("║ PDS URL: %-51s ║\n", p.PublicURL) 229 + fmt.Printf("║ Username: %-50s ║\n", p.did) 230 + fmt.Printf("║ ║\n") 231 + fmt.Printf("╚════════════════════════════════════════════════════════════════╝\n") 232 + fmt.Printf("\n") 233 + } else { 234 + fmt.Printf("✅ App passwords already exist (count: %d), skipping auto-generation\n", len(passwords)) 195 235 } 196 236 197 237 return nil
+381
pkg/hold/pds/session.go
··· 1 + package pds 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "strings" 9 + 10 + cbg "github.com/whyrusleeping/cbor-gen" 11 + "github.com/ipfs/go-cid" 12 + ) 13 + 14 + // CreateSessionRequest represents a session creation request 15 + type CreateSessionRequest struct { 16 + Identifier string `json:"identifier"` // DID or handle 17 + Password string `json:"password"` // App password 18 + } 19 + 20 + // CreateSessionResponse represents a successful session creation 21 + type CreateSessionResponse struct { 22 + AccessJwt string `json:"accessJwt"` 23 + RefreshJwt string `json:"refreshJwt"` 24 + Handle string `json:"handle"` 25 + DID string `json:"did"` 26 + DIDDoc map[string]interface{} `json:"didDoc,omitempty"` // Optional DID document 27 + Email string `json:"email,omitempty"` // Optional, not used for holds 28 + Active *bool `json:"active,omitempty"` // Optional account status 29 + Status string `json:"status,omitempty"` // Optional account status 30 + } 31 + 32 + // SessionInfo represents session information 33 + type SessionInfo struct { 34 + Handle string `json:"handle"` 35 + DID string `json:"did"` 36 + Email string `json:"email,omitempty"` 37 + } 38 + 39 + // HandleCreateSession handles com.atproto.server.createSession 40 + func (h *XRPCHandler) HandleCreateSession(w http.ResponseWriter, r *http.Request) { 41 + // Parse request 42 + var req CreateSessionRequest 43 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 44 + http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) 45 + return 46 + } 47 + 48 + // Validate required fields 49 + if req.Identifier == "" || req.Password == "" { 50 + http.Error(w, "identifier and password are required", http.StatusBadRequest) 51 + return 52 + } 53 + 54 + // Validate identifier matches this hold's DID or handle 55 + holdDID := h.pds.DID() 56 + // For did:web, handle is the domain part without "did:web:" prefix 57 + // e.g., "did:web:hold01.atcr.io" -> "hold01.atcr.io" 58 + holdHandle := strings.TrimPrefix(holdDID, "did:web:") 59 + 60 + // Normalize the identifier (strip "at://" prefix if present) 61 + identifier := strings.TrimPrefix(req.Identifier, "at://") 62 + 63 + // Accept any of: 64 + // 1. Full DID: "did:web:hold01.atcr.io" 65 + // 2. Handle (domain): "hold01.atcr.io" 66 + // 3. Either with "at://" prefix 67 + isValidIdentifier := identifier == holdDID || identifier == holdHandle 68 + 69 + if !isValidIdentifier { 70 + fmt.Printf("Invalid identifier: got %q, expected DID %q or handle %q\n", req.Identifier, holdDID, holdHandle) 71 + http.Error(w, "invalid identifier", http.StatusUnauthorized) 72 + return 73 + } 74 + 75 + // Validate app password 76 + _, err := h.pds.ValidateAnyAppPassword(req.Password) 77 + if err != nil { 78 + http.Error(w, "invalid password", http.StatusUnauthorized) 79 + return 80 + } 81 + 82 + // Issue access and refresh tokens 83 + accessToken, err := h.pds.IssueAccessToken(holdDID, holdHandle) 84 + if err != nil { 85 + http.Error(w, fmt.Sprintf("failed to issue access token: %v", err), http.StatusInternalServerError) 86 + return 87 + } 88 + 89 + refreshToken, err := h.pds.IssueRefreshToken(holdDID, holdHandle) 90 + if err != nil { 91 + http.Error(w, fmt.Sprintf("failed to issue refresh token: %v", err), http.StatusInternalServerError) 92 + return 93 + } 94 + 95 + // Return session response 96 + active := true 97 + response := CreateSessionResponse{ 98 + AccessJwt: accessToken, 99 + RefreshJwt: refreshToken, 100 + Handle: holdHandle, 101 + DID: holdDID, 102 + Active: &active, // Account is active 103 + } 104 + 105 + w.Header().Set("Content-Type", "application/json") 106 + json.NewEncoder(w).Encode(response) 107 + } 108 + 109 + // HandleRefreshSession handles com.atproto.server.refreshSession 110 + func (h *XRPCHandler) HandleRefreshSession(w http.ResponseWriter, r *http.Request) { 111 + // Extract refresh token from Authorization header 112 + authHeader := r.Header.Get("Authorization") 113 + if authHeader == "" { 114 + http.Error(w, "authorization header required", http.StatusUnauthorized) 115 + return 116 + } 117 + 118 + // Remove "Bearer " prefix 119 + refreshToken := strings.TrimPrefix(authHeader, "Bearer ") 120 + if refreshToken == authHeader { 121 + http.Error(w, "invalid authorization header format", http.StatusUnauthorized) 122 + return 123 + } 124 + 125 + // Validate refresh token 126 + claims, err := h.pds.ValidateRefreshToken(refreshToken) 127 + if err != nil { 128 + http.Error(w, fmt.Sprintf("invalid refresh token: %v", err), http.StatusUnauthorized) 129 + return 130 + } 131 + 132 + // Issue new access token (and optionally new refresh token) 133 + accessToken, err := h.pds.IssueAccessToken(claims.DID, claims.Handle) 134 + if err != nil { 135 + http.Error(w, fmt.Sprintf("failed to issue access token: %v", err), http.StatusInternalServerError) 136 + return 137 + } 138 + 139 + // Issue new refresh token (rotate refresh tokens for security) 140 + newRefreshToken, err := h.pds.IssueRefreshToken(claims.DID, claims.Handle) 141 + if err != nil { 142 + http.Error(w, fmt.Sprintf("failed to issue refresh token: %v", err), http.StatusInternalServerError) 143 + return 144 + } 145 + 146 + // Revoke old refresh token 147 + if err := h.pds.RevokeRefreshToken(refreshToken); err != nil { 148 + // Log but don't fail - new tokens are already issued 149 + fmt.Printf("Warning: failed to revoke old refresh token: %v\n", err) 150 + } 151 + 152 + // Return new tokens 153 + active := true 154 + response := CreateSessionResponse{ 155 + AccessJwt: accessToken, 156 + RefreshJwt: newRefreshToken, 157 + Handle: claims.Handle, 158 + DID: claims.DID, 159 + Active: &active, // Account is active 160 + } 161 + 162 + w.Header().Set("Content-Type", "application/json") 163 + json.NewEncoder(w).Encode(response) 164 + } 165 + 166 + // HandleGetSession handles com.atproto.server.getSession 167 + func (h *XRPCHandler) HandleGetSession(w http.ResponseWriter, r *http.Request) { 168 + // Extract access token from Authorization header 169 + authHeader := r.Header.Get("Authorization") 170 + if authHeader == "" { 171 + http.Error(w, "authorization header required", http.StatusUnauthorized) 172 + return 173 + } 174 + 175 + // Remove "Bearer " prefix 176 + accessToken := strings.TrimPrefix(authHeader, "Bearer ") 177 + if accessToken == authHeader { 178 + http.Error(w, "invalid authorization header format", http.StatusUnauthorized) 179 + return 180 + } 181 + 182 + // Validate access token 183 + claims, err := h.pds.ValidateAccessToken(accessToken) 184 + if err != nil { 185 + http.Error(w, fmt.Sprintf("invalid access token: %v", err), http.StatusUnauthorized) 186 + return 187 + } 188 + 189 + // Return session info 190 + response := SessionInfo{ 191 + Handle: claims.Handle, 192 + DID: claims.DID, 193 + } 194 + 195 + w.Header().Set("Content-Type", "application/json") 196 + json.NewEncoder(w).Encode(response) 197 + } 198 + 199 + // CreateRecordRequest represents a record creation request 200 + type CreateRecordRequest struct { 201 + Repo string `json:"repo"` // DID of the repository 202 + Collection string `json:"collection"` // Collection name (e.g., "app.bsky.feed.post") 203 + Rkey string `json:"rkey,omitempty"` // Optional record key (TID generated if not provided) 204 + Validate *bool `json:"validate,omitempty"` // Optional validation flag 205 + Record interface{} `json:"record"` // The record value (JSON object) 206 + } 207 + 208 + // CreateRecordResponse represents a successful record creation 209 + type CreateRecordResponse struct { 210 + URI string `json:"uri"` // at://did/collection/rkey 211 + CID string `json:"cid"` // Record CID 212 + } 213 + 214 + // RawRecord wraps a record value and implements CBORMarshaler 215 + // This allows us to accept any JSON record and marshal it to CBOR 216 + type RawRecord struct { 217 + Value map[string]interface{} 218 + } 219 + 220 + // MarshalCBOR implements CBORMarshaler for RawRecord 221 + func (r *RawRecord) MarshalCBOR(w io.Writer) error { 222 + // Write CBOR map header 223 + if err := cbg.WriteMajorTypeHeader(w, cbg.MajMap, uint64(len(r.Value))); err != nil { 224 + return err 225 + } 226 + 227 + // Write each key-value pair 228 + for key, val := range r.Value { 229 + // Write key as text string 230 + if err := cbg.WriteMajorTypeHeader(w, cbg.MajTextString, uint64(len(key))); err != nil { 231 + return err 232 + } 233 + if _, err := w.Write([]byte(key)); err != nil { 234 + return err 235 + } 236 + 237 + // Write value (simplified - handles common types) 238 + if err := writeValue(w, val); err != nil { 239 + return err 240 + } 241 + } 242 + 243 + return nil 244 + } 245 + 246 + // writeValue writes a value to CBOR (helper for RawRecord) 247 + func writeValue(w io.Writer, val interface{}) error { 248 + switch v := val.(type) { 249 + case string: 250 + if err := cbg.WriteMajorTypeHeader(w, cbg.MajTextString, uint64(len(v))); err != nil { 251 + return err 252 + } 253 + _, err := w.Write([]byte(v)) 254 + return err 255 + case int64: 256 + return cbg.CborWriteHeader(w, cbg.MajUnsignedInt, uint64(v)) 257 + case float64: 258 + // Write as unsigned int for now (simplified) 259 + return cbg.CborWriteHeader(w, cbg.MajUnsignedInt, uint64(v)) 260 + case bool: 261 + if v { 262 + return cbg.WriteBool(w, true) 263 + } 264 + return cbg.WriteBool(w, false) 265 + case map[string]interface{}: 266 + rec := &RawRecord{Value: v} 267 + return rec.MarshalCBOR(w) 268 + case []interface{}: 269 + if err := cbg.WriteMajorTypeHeader(w, cbg.MajArray, uint64(len(v))); err != nil { 270 + return err 271 + } 272 + for _, item := range v { 273 + if err := writeValue(w, item); err != nil { 274 + return err 275 + } 276 + } 277 + return nil 278 + default: 279 + // For unknown types, convert to JSON then write as string 280 + jsonBytes, err := json.Marshal(v) 281 + if err != nil { 282 + return err 283 + } 284 + if err := cbg.WriteMajorTypeHeader(w, cbg.MajTextString, uint64(len(jsonBytes))); err != nil { 285 + return err 286 + } 287 + _, err = w.Write(jsonBytes) 288 + return err 289 + } 290 + } 291 + 292 + // HandleCreateRecord handles com.atproto.repo.createRecord 293 + func (h *XRPCHandler) HandleCreateRecord(w http.ResponseWriter, r *http.Request) { 294 + // Validate JWT authentication 295 + user, err := ValidateJWTAuth(r, h.pds) 296 + if err != nil { 297 + http.Error(w, fmt.Sprintf("authentication required: %v", err), http.StatusUnauthorized) 298 + return 299 + } 300 + 301 + // Parse request 302 + var req CreateRecordRequest 303 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 304 + http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) 305 + return 306 + } 307 + 308 + // Validate required fields 309 + if req.Repo == "" || req.Collection == "" || req.Record == nil { 310 + http.Error(w, "repo, collection, and record are required", http.StatusBadRequest) 311 + return 312 + } 313 + 314 + // Verify repo matches authenticated user 315 + if req.Repo != user.DID { 316 + http.Error(w, "repo must match authenticated user DID", http.StatusForbidden) 317 + return 318 + } 319 + 320 + // Verify repo matches this hold's DID 321 + if req.Repo != h.pds.DID() { 322 + http.Error(w, "invalid repo (must be this hold's DID)", http.StatusBadRequest) 323 + return 324 + } 325 + 326 + // Convert record from JSON to CBOR-marshalable format 327 + recordMap, ok := req.Record.(map[string]interface{}) 328 + if !ok { 329 + http.Error(w, "record must be a JSON object", http.StatusBadRequest) 330 + return 331 + } 332 + 333 + // Wrap in RawRecord which implements CBORMarshaler 334 + recordValue := &RawRecord{Value: recordMap} 335 + 336 + // Create record using repomgr 337 + var recordPath string 338 + var recordCID cid.Cid 339 + 340 + if req.Rkey != "" { 341 + // Use PutRecord if rkey is specified 342 + recordPath, recordCID, err = h.pds.repomgr.PutRecord( 343 + r.Context(), 344 + h.pds.uid, 345 + req.Collection, 346 + req.Rkey, 347 + recordValue, 348 + ) 349 + } else { 350 + // Use CreateRecord if no rkey (auto-generates TID) 351 + recordPath, recordCID, err = h.pds.repomgr.CreateRecord( 352 + r.Context(), 353 + h.pds.uid, 354 + req.Collection, 355 + recordValue, 356 + ) 357 + } 358 + 359 + if err != nil { 360 + http.Error(w, fmt.Sprintf("failed to create record: %v", err), http.StatusInternalServerError) 361 + return 362 + } 363 + 364 + // Extract rkey from path (format: "collection/rkey") 365 + parts := strings.Split(recordPath, "/") 366 + if len(parts) < 2 { 367 + http.Error(w, "invalid record path returned", http.StatusInternalServerError) 368 + return 369 + } 370 + actualRkey := parts[len(parts)-1] 371 + 372 + // Return success response 373 + response := CreateRecordResponse{ 374 + URI: fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), req.Collection, actualRkey), 375 + CID: recordCID.String(), 376 + } 377 + 378 + w.Header().Set("Content-Type", "application/json") 379 + w.WriteHeader(http.StatusCreated) 380 + json.NewEncoder(w).Encode(response) 381 + }
+217 -23
pkg/hold/pds/xrpc.go
··· 8 8 9 9 "atcr.io/pkg/atproto" 10 10 "atcr.io/pkg/s3" 11 + "github.com/bluesky-social/indigo/api/bsky" 11 12 lexutil "github.com/bluesky-social/indigo/lex/util" 12 13 "github.com/bluesky-social/indigo/repo" 13 14 "github.com/distribution/distribution/v3/registry/storage/driver" ··· 73 74 } 74 75 } 75 76 76 - // corsMiddleware is chi-compatible middleware that adds CORS headers 77 - func (h *XRPCHandler) corsMiddleware(next http.Handler) http.Handler { 78 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 - w.Header().Set("Access-Control-Allow-Origin", "*") 80 - w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS") 81 - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, DPoP, X-Upload-Id, X-Part-Number, X-ATCR-DID") 77 + // CORSMiddleware returns a simple CORS middleware configured for ATProto 78 + // This should be applied in the main router before registering any routes 79 + func (h *XRPCHandler) CORSMiddleware() func(http.Handler) http.Handler { 80 + return func(next http.Handler) http.Handler { 81 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 82 + // Set CORS headers for all requests 83 + origin := r.Header.Get("Origin") 84 + if origin == "" { 85 + origin = "*" 86 + } 87 + w.Header().Set("Access-Control-Allow-Origin", origin) 88 + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS") 89 + w.Header().Set("Access-Control-Allow-Headers", "*") 90 + w.Header().Set("Access-Control-Expose-Headers", "*") 91 + w.Header().Set("Access-Control-Max-Age", "300") 82 92 83 - // Handle preflight OPTIONS requests 84 - if r.Method == http.MethodOptions { 85 - w.WriteHeader(http.StatusOK) 86 - return 87 - } 93 + // Handle OPTIONS preflight 94 + if r.Method == "OPTIONS" { 95 + w.WriteHeader(http.StatusNoContent) 96 + return 97 + } 88 98 89 - next.ServeHTTP(w, r) 90 - }) 99 + next.ServeHTTP(w, r) 100 + }) 101 + } 91 102 } 92 103 93 104 // requireOwnerOrCrewAdmin middleware - validates owner or crew admin access ··· 131 142 } 132 143 133 144 // RegisterHandlers registers all XRPC endpoints using chi router 145 + // Note: CORS middleware must be applied in the main router before calling this 134 146 func (h *XRPCHandler) RegisterHandlers(r chi.Router) { 135 - // Public read-only endpoints (CORS only, no auth) 147 + // Public read-only endpoints (no auth) 136 148 r.Group(func(r chi.Router) { 137 - r.Use(h.corsMiddleware) 138 - 139 149 // Health and server info 140 150 r.Get("/xrpc/_health", h.HandleHealth) 141 151 r.Get(atproto.ServerDescribeServer, h.HandleDescribeServer) 142 152 153 + // Session management (public - creates sessions) 154 + r.Post(atproto.ServerCreateSession, h.HandleCreateSession) 155 + r.Post(atproto.ServerRefreshSession, h.HandleRefreshSession) 156 + r.Get(atproto.ServerGetSession, h.HandleGetSession) 157 + 143 158 // Repository metadata 144 159 r.Get(atproto.RepoDescribeRepo, h.HandleDescribeRepo) 145 160 r.Get(atproto.RepoGetRecord, h.HandleGetRecord) ··· 154 169 // DID document and handle resolution 155 170 r.Get("/.well-known/did.json", h.HandleDIDDocument) 156 171 r.Get("/.well-known/atproto-did", h.HandleAtprotoDID) 172 + 173 + // Identity and profile endpoints 174 + r.Get(atproto.IdentityResolveHandle, h.HandleResolveHandle) 175 + r.Get(atproto.ActorGetProfile, h.HandleGetProfile) 176 + r.Get(atproto.ActorGetProfiles, h.HandleGetProfiles) 157 177 }) 158 178 159 - // Blob read endpoints (CORS + conditional auth based on captain.public) 179 + // Blob read endpoints (conditional auth based on captain.public) 160 180 // Auth is handled inside HandleGetBlob 161 181 r.Group(func(r chi.Router) { 162 - r.Use(h.corsMiddleware) 163 - 164 182 r.Get(atproto.SyncGetBlob, h.HandleGetBlob) 165 183 r.Head(atproto.SyncGetBlob, h.HandleGetBlob) 166 184 }) 167 185 168 - // Write endpoints (CORS + owner/crew admin auth) 186 + // Write endpoints (owner/crew admin auth) 169 187 r.Group(func(r chi.Router) { 170 - r.Use(h.corsMiddleware) 171 188 r.Use(h.requireOwnerOrCrewAdmin) 172 189 173 190 r.Post(atproto.RepoDeleteRecord, h.HandleDeleteRecord) 174 191 r.Post(atproto.RepoUploadBlob, h.HandleUploadBlob) 175 192 }) 176 193 177 - // Auth-only endpoints (CORS + DPoP auth) 194 + // Auth-only endpoints (DPoP auth) 178 195 r.Group(func(r chi.Router) { 179 - r.Use(h.corsMiddleware) 180 196 r.Use(h.requireAuth) 181 197 182 198 r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew) 199 + }) 200 + 201 + // JWT-authenticated endpoints (JWT auth from createSession) 202 + // Note: JWT auth is validated inside each handler 203 + r.Group(func(r chi.Router) { 204 + r.Post("/xrpc/com.atproto.repo.createRecord", h.HandleCreateRecord) 183 205 }) 184 206 } 185 207 ··· 211 233 212 234 w.Header().Set("Content-Type", "application/json") 213 235 json.NewEncoder(w).Encode(response) 236 + } 237 + 238 + // HandleResolveHandle resolves a handle to a DID 239 + func (h *XRPCHandler) HandleResolveHandle(w http.ResponseWriter, r *http.Request) { 240 + // Get handle parameter 241 + handle := r.URL.Query().Get("handle") 242 + if handle == "" { 243 + http.Error(w, "handle parameter required", http.StatusBadRequest) 244 + return 245 + } 246 + 247 + // For this hold PDS, the handle is the domain part of the DID 248 + // e.g., "hold01.atcr.io did:web:hold01.atcr.io" 249 + expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:") 250 + 251 + // Check if the handle matches 252 + if handle != expectedHandle { 253 + http.Error(w, "handle not found", http.StatusNotFound) 254 + return 255 + } 256 + 257 + // Return the DID 258 + response := map[string]string{ 259 + "did": h.pds.DID(), 260 + } 261 + 262 + w.Header().Set("Content-Type", "application/json") 263 + json.NewEncoder(w).Encode(response) 264 + } 265 + 266 + // HandleGetProfile returns aggregated profile information 267 + func (h *XRPCHandler) HandleGetProfile(w http.ResponseWriter, r *http.Request) { 268 + // Get actor parameter (can be DID or handle) 269 + actor := r.URL.Query().Get("actor") 270 + if actor == "" { 271 + http.Error(w, "actor parameter required", http.StatusBadRequest) 272 + return 273 + } 274 + 275 + // Normalize actor to DID 276 + actorDID := actor 277 + if !strings.HasPrefix(actor, "did:") { 278 + // It's a handle, resolve to DID 279 + expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:") 280 + if actor == expectedHandle { 281 + actorDID = h.pds.DID() 282 + } else { 283 + http.Error(w, "actor not found", http.StatusNotFound) 284 + return 285 + } 286 + } 287 + 288 + // Verify it's this hold's DID 289 + if actorDID != h.pds.DID() { 290 + http.Error(w, "actor not found", http.StatusNotFound) 291 + return 292 + } 293 + 294 + // Build profile response using shared function 295 + response := h.buildProfileResponse(r.Context()) 296 + 297 + w.Header().Set("Content-Type", "application/json") 298 + json.NewEncoder(w).Encode(response) 299 + } 300 + 301 + // HandleGetProfiles returns aggregated profile information for multiple actors 302 + func (h *XRPCHandler) HandleGetProfiles(w http.ResponseWriter, r *http.Request) { 303 + // Get actors parameters (can be multiple) 304 + actors := r.URL.Query()["actors"] 305 + if len(actors) == 0 { 306 + http.Error(w, "actors parameter required", http.StatusBadRequest) 307 + return 308 + } 309 + 310 + // Initialize profiles array (always return array, even if empty) 311 + profiles := []map[string]any{} 312 + 313 + // Expected handle for this hold 314 + expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:") 315 + 316 + // Check each actor to see if it matches this hold's DID 317 + for _, actor := range actors { 318 + // Normalize actor to DID 319 + actorDID := actor 320 + if !strings.HasPrefix(actor, "did:") { 321 + // It's a handle, check if it matches 322 + if actor == expectedHandle { 323 + actorDID = h.pds.DID() 324 + } else { 325 + // Not this hold's handle, skip 326 + continue 327 + } 328 + } 329 + 330 + // Check if it's this hold's DID 331 + if actorDID != h.pds.DID() { 332 + // Not this hold, skip 333 + continue 334 + } 335 + 336 + // Build profile for this hold 337 + profile := h.buildProfileResponse(r.Context()) 338 + if profile != nil { 339 + profiles = append(profiles, profile) 340 + } 341 + } 342 + 343 + // Return profiles array 344 + response := map[string]any{ 345 + "profiles": profiles, 346 + } 347 + 348 + w.Header().Set("Content-Type", "application/json") 349 + json.NewEncoder(w).Encode(response) 350 + } 351 + 352 + // buildProfileResponse builds a profile response map (shared by GetProfile and GetProfiles) 353 + func (h *XRPCHandler) buildProfileResponse(ctx context.Context) map[string]any { 354 + // Get profile record from repo 355 + _, profileVal, err := h.pds.repomgr.GetRecord( 356 + ctx, 357 + h.pds.uid, 358 + "app.bsky.actor.profile", 359 + "self", 360 + cid.Undef, 361 + ) 362 + 363 + // Base response with minimal info 364 + response := map[string]any{ 365 + "did": h.pds.DID(), 366 + "handle": strings.TrimPrefix(h.pds.DID(), "did:web:"), 367 + "postsCount": 0, 368 + "followersCount": 0, 369 + "followsCount": 0, 370 + } 371 + 372 + // Count posts 373 + session, err := h.pds.carstore.ReadOnlySession(h.pds.uid) 374 + if err == nil { 375 + head, err := h.pds.carstore.GetUserRepoHead(ctx, h.pds.uid) 376 + if err == nil && head.Defined() { 377 + repoHandle, err := repo.OpenRepo(ctx, session, head) 378 + if err == nil { 379 + postCount := 0 380 + _ = repoHandle.ForEach(ctx, "app.bsky.feed.post", func(k string, v cid.Cid) error { 381 + postCount++ 382 + return nil 383 + }) 384 + response["postsCount"] = postCount 385 + } 386 + } 387 + } 388 + 389 + // Add profile fields if profile record exists 390 + if err == nil { 391 + profileRecord, ok := profileVal.(*bsky.ActorProfile) 392 + if ok { 393 + if profileRecord.DisplayName != nil && *profileRecord.DisplayName != "" { 394 + response["displayName"] = *profileRecord.DisplayName 395 + } 396 + if profileRecord.Description != nil && *profileRecord.Description != "" { 397 + response["description"] = *profileRecord.Description 398 + } 399 + if profileRecord.Avatar != nil { 400 + avatarURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 401 + h.pds.PublicURL, h.pds.DID(), profileRecord.Avatar.Ref.String()) 402 + response["avatar"] = avatarURL 403 + } 404 + } 405 + } 406 + 407 + return response 214 408 } 215 409 216 410 // HandleDescribeRepo returns repository information