A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
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