remove user oauth flow. hold now contains captain record indicating owner

This commit is contained in:
Evan Jarrett
2025-10-15 14:47:53 -05:00
parent a271d3d8e3
commit fade86abaa
12 changed files with 1087 additions and 562 deletions
+194
View File
@@ -0,0 +1,194 @@
package pds
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// ValidatedUser represents a successfully validated user from DPoP + OAuth
type ValidatedUser struct {
DID string
Handle string
PDS string
Authorized bool
}
// ValidateDPoPRequest validates a request with DPoP + OAuth tokens
// This implements the standard ATProto token validation flow:
// 1. Extract Authorization header (DPoP <token>)
// 2. Extract DPoP header (proof JWT)
// 3. Call user's PDS to validate token via com.atproto.server.getSession
// 4. Return validated user DID
func ValidateDPoPRequest(r *http.Request) (*ValidatedUser, error) {
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil, fmt.Errorf("missing Authorization header")
}
// Check for DPoP authorization scheme
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid Authorization header format")
}
if parts[0] != "DPoP" {
return nil, fmt.Errorf("expected DPoP authorization scheme, got: %s", parts[0])
}
accessToken := parts[1]
if accessToken == "" {
return nil, fmt.Errorf("missing access token")
}
// Extract DPoP header
dpopProof := r.Header.Get("DPoP")
if dpopProof == "" {
return nil, fmt.Errorf("missing DPoP header")
}
// TODO: We could verify the DPoP proof locally (signature, HTM, HTU, etc.)
// For now, we'll rely on the PDS to validate everything
// The token contains the user's DID in its claims, but we can't trust it without validation
// We need to call the user's PDS to validate the token
// Problem: We don't know which PDS to call yet!
// For now, we'll parse the JWT to extract the DID/PDS hint (unverified)
// Then validate against that PDS
// This is safe because the PDS will verify the token is valid for that DID
did, pds, err := extractDIDFromToken(accessToken)
if err != nil {
return nil, fmt.Errorf("failed to extract DID from token: %w", err)
}
// Validate token with the user's PDS
session, err := validateTokenWithPDS(r.Context(), pds, accessToken, dpopProof)
if err != nil {
return nil, fmt.Errorf("token validation failed: %w", err)
}
// Verify the DID matches
if session.DID != did {
return nil, fmt.Errorf("token DID mismatch: expected %s, got %s", did, session.DID)
}
return &ValidatedUser{
DID: session.DID,
Handle: session.Handle,
PDS: pds,
Authorized: true,
}, nil
}
// extractDIDFromToken extracts the DID and PDS from an unverified JWT token
// This is just for routing purposes - the token will be validated by the PDS
func extractDIDFromToken(token string) (string, string, error) {
// JWT format: header.payload.signature
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", "", fmt.Errorf("invalid JWT format")
}
// Decode payload (base64url)
payload, err := decodeBase64URL(parts[1])
if err != nil {
return "", "", fmt.Errorf("failed to decode payload: %w", err)
}
// Parse JSON
var claims struct {
Sub string `json:"sub"` // DID
Iss string `json:"iss"` // PDS URL (issuer)
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", "", fmt.Errorf("failed to parse claims: %w", err)
}
if claims.Sub == "" {
return "", "", fmt.Errorf("missing sub claim (DID)")
}
if claims.Iss == "" {
return "", "", fmt.Errorf("missing iss claim (PDS)")
}
return claims.Sub, claims.Iss, nil
}
// decodeBase64URL decodes base64url (RFC 4648)
func decodeBase64URL(s string) ([]byte, error) {
// Use Go's RawURLEncoding (base64url without padding)
return base64.RawURLEncoding.DecodeString(s)
}
// SessionResponse represents the response from com.atproto.server.getSession
type SessionResponse struct {
DID string `json:"did"`
Handle string `json:"handle"`
}
// validateTokenWithPDS calls the user's PDS to validate the token
func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof string) (*SessionResponse, error) {
// Call com.atproto.server.getSession with DPoP headers
url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", strings.TrimSuffix(pdsURL, "/"))
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add DPoP authorization headers
req.Header.Set("Authorization", "DPoP "+accessToken)
req.Header.Set("DPoP", dpopProof)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call PDS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body))
}
var session SessionResponse
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, fmt.Errorf("failed to decode session: %w", err)
}
return &session, nil
}
// ResolveDIDToPDS resolves a DID to its PDS endpoint (for reference)
// This is an alternative approach if we don't trust the token's issuer claim
func ResolveDIDToPDS(ctx context.Context, did string) (string, error) {
directory := identity.DefaultDirectory()
didParsed, err := syntax.ParseDID(did)
if err != nil {
return "", fmt.Errorf("invalid DID: %w", err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
return "", fmt.Errorf("failed to resolve DID: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return "", fmt.Errorf("no PDS endpoint found for DID")
}
return pdsEndpoint, nil
}
+146
View File
@@ -0,0 +1,146 @@
package pds
import (
"bytes"
"context"
"fmt"
"time"
"github.com/bluesky-social/indigo/repo"
"github.com/ipfs/go-cid"
)
const (
// CaptainRkey is the fixed rkey for the captain record (singleton)
CaptainRkey = "self"
)
// CreateCaptainRecord creates the captain record for the hold
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) (cid.Cid, error) {
captainRecord := &CaptainRecord{
Type: CaptainCollection,
Owner: ownerDID,
Public: public,
AllowAllCrew: allowAllCrew,
DeployedAt: time.Now().Format(time.RFC3339),
}
// Create record in repo with fixed rkey "self"
recordCID, rkey, err := p.repo.CreateRecord(ctx, CaptainCollection, captainRecord)
if err != nil {
return cid.Undef, fmt.Errorf("failed to create captain record: %w", err)
}
// Create signer function from signing key
signer := func(ctx context.Context, did string, data []byte) ([]byte, error) {
return p.signingKey.HashAndSign(data)
}
// Commit the changes to get new root CID
root, rev, err := p.repo.Commit(ctx, signer)
if err != nil {
return cid.Undef, fmt.Errorf("failed to commit captain record: %w", err)
}
// Close the delta session with the new root
_, err = p.session.CloseWithRoot(ctx, root, rev)
if err != nil {
return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
}
// Create a new session for the next operation
rootStr := root.String()
newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
if err != nil {
return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
}
// Load repo from the newly committed head
newRepo, err := repo.OpenRepo(ctx, newSession, root)
if err != nil {
return cid.Undef, fmt.Errorf("failed to reload repo after commit: %w", err)
}
// Update the stored session and repo
p.session = newSession
p.repo = newRepo
fmt.Printf("Created captain record with rkey: %s, cid: %s\n", rkey, recordCID)
return recordCID, nil
}
// GetCaptainRecord retrieves the captain record
func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *CaptainRecord, error) {
path := fmt.Sprintf("%s/%s", CaptainCollection, CaptainRkey)
// Get the record bytes and decode manually
recordCID, recBytes, err := p.repo.GetRecordBytes(ctx, path)
if err != nil {
return cid.Undef, nil, fmt.Errorf("failed to get captain record: %w", err)
}
// Decode the CBOR bytes into our CaptainRecord type
var captainRecord CaptainRecord
if err := captainRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
return cid.Undef, nil, fmt.Errorf("failed to decode captain record: %w", err)
}
return recordCID, &captainRecord, nil
}
// UpdateCaptainRecord updates the captain record (e.g., to change public/allowAllCrew settings)
func (p *HoldPDS) UpdateCaptainRecord(ctx context.Context, public bool, allowAllCrew bool) (cid.Cid, error) {
// Get existing record to preserve other fields
_, existing, err := p.GetCaptainRecord(ctx)
if err != nil {
return cid.Undef, fmt.Errorf("failed to get existing captain record: %w", err)
}
// Update the fields
existing.Public = public
existing.AllowAllCrew = allowAllCrew
// Update record in repo
path := fmt.Sprintf("%s/%s", CaptainCollection, CaptainRkey)
recordCID, err := p.repo.UpdateRecord(ctx, path, existing)
if err != nil {
return cid.Undef, fmt.Errorf("failed to update captain record: %w", err)
}
// Create signer function from signing key
signer := func(ctx context.Context, did string, data []byte) ([]byte, error) {
return p.signingKey.HashAndSign(data)
}
// Commit the changes
root, rev, err := p.repo.Commit(ctx, signer)
if err != nil {
return cid.Undef, fmt.Errorf("failed to commit captain record update: %w", err)
}
// Close the delta session with the new root
_, err = p.session.CloseWithRoot(ctx, root, rev)
if err != nil {
return cid.Undef, fmt.Errorf("failed to persist commit: %w", err)
}
// Create a new session for the next operation
rootStr := root.String()
newSession, err := p.carstore.NewDeltaSession(ctx, p.uid, &rootStr)
if err != nil {
return cid.Undef, fmt.Errorf("failed to create new session: %w", err)
}
// Load repo from the newly committed head
newRepo, err := repo.OpenRepo(ctx, newSession, root)
if err != nil {
return cid.Undef, fmt.Errorf("failed to reload repo after commit: %w", err)
}
// Update the stored session and repo
p.session = newSession
p.repo = newRepo
return recordCID, nil
}
+319
View File
@@ -293,3 +293,322 @@ func (t *CrewRecord) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
return err
}
cw := cbg.NewCborWriter(w)
fieldCount := 7
if t.Region == "" {
fieldCount--
}
if t.Provider == "" {
fieldCount--
}
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
return err
}
// t.Type (string) (string)
if len("$type") > 8192 {
return xerrors.Errorf("Value in field \"$type\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
return err
}
if _, err := cw.WriteString(string("$type")); err != nil {
return err
}
if len(t.Type) > 8192 {
return xerrors.Errorf("Value in field t.Type was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Type)); err != nil {
return err
}
// t.Owner (string) (string)
if len("owner") > 8192 {
return xerrors.Errorf("Value in field \"owner\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
return err
}
if _, err := cw.WriteString(string("owner")); err != nil {
return err
}
if len(t.Owner) > 8192 {
return xerrors.Errorf("Value in field t.Owner was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Owner)); err != nil {
return err
}
// t.Public (bool) (bool)
if len("public") > 8192 {
return xerrors.Errorf("Value in field \"public\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("public"))); err != nil {
return err
}
if _, err := cw.WriteString(string("public")); err != nil {
return err
}
if err := cbg.WriteBool(w, t.Public); err != nil {
return err
}
// t.Region (string) (string)
if t.Region != "" {
if len("region") > 8192 {
return xerrors.Errorf("Value in field \"region\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("region"))); err != nil {
return err
}
if _, err := cw.WriteString(string("region")); err != nil {
return err
}
if len(t.Region) > 8192 {
return xerrors.Errorf("Value in field t.Region was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Region))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Region)); err != nil {
return err
}
}
// t.Provider (string) (string)
if t.Provider != "" {
if len("provider") > 8192 {
return xerrors.Errorf("Value in field \"provider\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("provider"))); err != nil {
return err
}
if _, err := cw.WriteString(string("provider")); err != nil {
return err
}
if len(t.Provider) > 8192 {
return xerrors.Errorf("Value in field t.Provider was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Provider))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Provider)); err != nil {
return err
}
}
// t.DeployedAt (string) (string)
if len("deployedAt") > 8192 {
return xerrors.Errorf("Value in field \"deployedAt\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("deployedAt"))); err != nil {
return err
}
if _, err := cw.WriteString(string("deployedAt")); err != nil {
return err
}
if len(t.DeployedAt) > 8192 {
return xerrors.Errorf("Value in field t.DeployedAt was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.DeployedAt))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.DeployedAt)); err != nil {
return err
}
// t.AllowAllCrew (bool) (bool)
if len("allowAllCrew") > 8192 {
return xerrors.Errorf("Value in field \"allowAllCrew\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("allowAllCrew"))); err != nil {
return err
}
if _, err := cw.WriteString(string("allowAllCrew")); err != nil {
return err
}
if err := cbg.WriteBool(w, t.AllowAllCrew); err != nil {
return err
}
return nil
}
func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
*t = CaptainRecord{}
cr := cbg.NewCborReader(r)
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
defer func() {
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
}()
if maj != cbg.MajMap {
return fmt.Errorf("cbor input should be of type map")
}
if extra > cbg.MaxLength {
return fmt.Errorf("CaptainRecord: map struct too large (%d)", extra)
}
n := extra
nameBuf := make([]byte, 12)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
if err != nil {
return err
}
if !ok {
// Field doesn't exist on this type, so ignore it
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
return err
}
continue
}
switch string(nameBuf[:nameLen]) {
// t.Type (string) (string)
case "$type":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Type = string(sval)
}
// t.Owner (string) (string)
case "owner":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Owner = string(sval)
}
// t.Public (bool) (bool)
case "public":
maj, extra, err = cr.ReadHeader()
if err != nil {
return err
}
if maj != cbg.MajOther {
return fmt.Errorf("booleans must be major type 7")
}
switch extra {
case 20:
t.Public = false
case 21:
t.Public = true
default:
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
}
// t.Region (string) (string)
case "region":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Region = string(sval)
}
// t.Provider (string) (string)
case "provider":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Provider = string(sval)
}
// t.DeployedAt (string) (string)
case "deployedAt":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.DeployedAt = string(sval)
}
// t.AllowAllCrew (bool) (bool)
case "allowAllCrew":
maj, extra, err = cr.ReadHeader()
if err != nil {
return err
}
if maj != cbg.MajOther {
return fmt.Errorf("booleans must be major type 7")
}
switch extra {
case 20:
t.AllowAllCrew = false
case 21:
t.AllowAllCrew = true
default:
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
}
default:
// Field doesn't exist on this type, so ignore it
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
return err
}
}
}
return nil
}
+5
View File
@@ -77,6 +77,11 @@ func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
Type: "AtprotoPersonalDataServer",
ServiceEndpoint: publicURL,
},
{
ID: "#atcr_hold",
Type: "AtcrHoldService",
ServiceEndpoint: publicURL,
},
},
}
+10 -2
View File
@@ -98,8 +98,8 @@ func (p *HoldPDS) SigningKey() *atcrypto.PrivateKeyK256 {
return p.signingKey
}
// Bootstrap initializes the hold with the owner as the first crew member
func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string) error {
// Bootstrap initializes the hold with the captain record and owner as first crew member
func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) error {
if ownerDID == "" {
return nil
}
@@ -115,6 +115,14 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string) error {
return nil
}
// Create captain record (hold ownership and settings)
_, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew)
if err != nil {
return fmt.Errorf("failed to create captain record: %w", err)
}
fmt.Printf("✅ Created captain record (public=%v, allowAllCrew=%v)\n", public, allowAllCrew)
// Add hold owner as first crew member with admin role
_, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
if err != nil {
+17 -1
View File
@@ -1,8 +1,23 @@
package pds
//go:generate go run github.com/whyrusleeping/cbor-gen --map-encoding CrewRecord CaptainRecord
// ATProto record types for the hold service
// CaptainRecord represents the hold's ownership and metadata
// Collection: io.atcr.hold.captain (single record per hold)
type CaptainRecord struct {
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
}
// CrewRecord represents a crew member in the hold
// Collection: io.atcr.hold.crew (one record per member)
type CrewRecord struct {
Type string `json:"$type" cborgen:"$type"`
Member string `json:"member" cborgen:"member"`
@@ -12,5 +27,6 @@ type CrewRecord struct {
}
const (
CrewCollection = "io.atcr.hold.crew"
CaptainCollection = "io.atcr.hold.captain"
CrewCollection = "io.atcr.hold.crew"
)
+103
View File
@@ -79,6 +79,9 @@ func (h *XRPCHandler) RegisterHandlers(mux *http.ServeMux) {
// DID document and handle resolution
mux.HandleFunc("/.well-known/did.json", corsMiddleware(h.HandleDIDDocument))
mux.HandleFunc("/.well-known/atproto-did", corsMiddleware(h.HandleAtprotoDID))
// Custom ATCR endpoints
mux.HandleFunc("/xrpc/io.atcr.hold.requestCrew", corsMiddleware(h.HandleRequestCrew))
}
// HandleHealth returns health check information
@@ -480,3 +483,103 @@ func (h *XRPCHandler) HandleAtprotoDID(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, h.pds.DID())
}
// HandleRequestCrew handles crew membership requests
// This endpoint allows authenticated users to request crew membership
// Authorization is checked against captain record settings
func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate DPoP + OAuth token from Authorization and DPoP headers
user, err := ValidateDPoPRequest(r)
if err != nil {
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
return
}
// Parse request body (optional parameters)
var req struct {
Role string `json:"role"` // Requested role (default: "member")
Permissions []string `json:"permissions"` // Requested permissions
}
// Body is optional - if empty, just use defaults
if r.Body != nil && r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
}
// Get captain record to check authorization settings
_, captain, err := h.pds.GetCaptainRecord(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to get captain record: %v", err), http.StatusInternalServerError)
return
}
// Check authorization:
// 1. If allowAllCrew is true, any authenticated user can join
// 2. If user is the owner, they can always join (though they should already be crew)
// 3. Otherwise, deny
isOwner := user.DID == captain.Owner
if !captain.AllowAllCrew && !isOwner {
http.Error(w, "crew registration not allowed (HOLD_ALLOW_ALL_CREW=false)", http.StatusForbidden)
return
}
// Set defaults if not provided
if req.Role == "" {
req.Role = "member"
}
if len(req.Permissions) == 0 {
req.Permissions = []string{"blob:read", "blob:write"}
}
// Check if user is already a crew member
// List all crew members and check if this DID is already present
crew, err := h.pds.ListCrewMembers(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to list crew members: %v", err), http.StatusInternalServerError)
return
}
for _, member := range crew {
if member.Record.Member == user.DID {
// Already a crew member, return success with existing record
response := map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), CrewCollection, member.Rkey),
"cid": member.Cid.String(),
"status": "already_member",
"message": "User is already a crew member",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
return
}
}
// Create new crew record
recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError)
return
}
// Return success response
// Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it
// For now, return just the CID. In production, AddCrewMember should return both CID and rkey
response := map[string]any{
"cid": recordCID.String(),
"status": "created",
"message": "Successfully added to crew",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
-481
View File
@@ -1,481 +0,0 @@
package hold
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// HealthHandler handles health check requests
func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}
// isHoldRegistered checks if a hold with the given public URL is already registered in the PDS
func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) {
// We need to query the PDS without authentication to check public records
// ATProto records are publicly readable, so we can use an unauthenticated client
client := atproto.NewClient(pdsEndpoint, did, "")
// List all hold records for this DID
records, err := client.ListRecords(ctx, atproto.HoldCollection, 100)
if err != nil {
return false, fmt.Errorf("failed to list hold records: %w", err)
}
// Check if any hold record matches our public URL
for _, record := range records {
var holdRecord atproto.HoldRecord
if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
continue
}
if holdRecord.Endpoint == publicURL {
return true, nil
}
}
return false, nil
}
// AutoRegister registers this hold service in the owner's PDS
// Checks if already registered first, then does OAuth if needed
func (s *HoldService) AutoRegister(callbackHandler *http.HandlerFunc) error {
reg := &s.config.Registration
publicURL := s.config.Server.PublicURL
if publicURL == "" {
return fmt.Errorf("HOLD_PUBLIC_URL not set")
}
if reg.OwnerDID == "" {
return fmt.Errorf("HOLD_OWNER not set - required for registration")
}
ctx := context.Background()
log.Printf("Checking registration status for DID: %s", reg.OwnerDID)
// Resolve DID to PDS endpoint using indigo
directory := identity.DefaultDirectory()
didParsed, err := syntax.ParseDID(reg.OwnerDID)
if err != nil {
return fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
return fmt.Errorf("failed to resolve PDS for DID: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return fmt.Errorf("no PDS endpoint found for DID")
}
log.Printf("PDS endpoint: %s", pdsEndpoint)
// Check if hold is already registered
isRegistered, err := s.isHoldRegistered(ctx, reg.OwnerDID, pdsEndpoint, publicURL)
if err != nil {
log.Printf("Warning: failed to check registration status: %v", err)
log.Printf("Proceeding with OAuth registration...")
} else if isRegistered {
log.Printf("✓ Hold service already registered in PDS")
log.Printf("Public URL: %s", publicURL)
return nil
}
// Not registered, need to do OAuth
log.Printf("Hold not registered, starting OAuth flow...")
// Get handle from DID document (already resolved above)
handle := ident.Handle.String()
if handle == "" || handle == "handle.invalid" {
return fmt.Errorf("no valid handle found for DID")
}
log.Printf("Resolved handle: %s", handle)
log.Printf("Starting OAuth registration for hold service")
log.Printf("Public URL: %s", publicURL)
return s.registerWithOAuth(publicURL, handle, reg.OwnerDID, pdsEndpoint, callbackHandler)
}
// registerWithOAuth performs OAuth flow and registers the hold
func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string, callbackHandler *http.HandlerFunc) error {
// Run OAuth flow to get authenticated client
client, err := s.runOAuthFlow(callbackHandler, "Hold service registration")
if err != nil {
return err
}
log.Printf("Authorization received!")
log.Printf("OAuth session obtained successfully")
log.Printf("DID: %s", did)
log.Printf("PDS: %s", pdsEndpoint)
return s.registerWithClient(publicURL, did, client)
}
// registerWithClient registers the hold using an authenticated ATProto client
func (s *HoldService) registerWithClient(publicURL, did string, client *atproto.Client) error {
// Derive hold name from URL (hostname)
holdName, err := extractHostname(publicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname from URL: %w", err)
}
log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did)
ctx := context.Background()
// Create HoldRecord
holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public)
// Use hostname as record key
holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord)
if err != nil {
return fmt.Errorf("failed to create hold record: %w", err)
}
log.Printf("✓ Created hold record: %s", holdResult.URI)
// Create HoldCrewRecord for the owner
crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, did, "owner")
crewRKey := fmt.Sprintf("%s-%s", holdName, did)
crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
if err != nil {
return fmt.Errorf("failed to create crew record: %w", err)
}
log.Printf("✓ Created crew record: %s", crewResult.URI)
// Update sailor profile to set this as the default hold
profile, err := atproto.GetProfile(ctx, client)
if err != nil {
log.Printf("Warning: failed to get sailor profile: %v", err)
} else {
if profile == nil {
// Create new profile with this hold as default
profile = atproto.NewSailorProfileRecord(publicURL)
} else {
// Update existing profile with new defaultHold
profile.DefaultHold = publicURL
profile.UpdatedAt = time.Now()
}
err = atproto.UpdateProfile(ctx, client, profile)
if err != nil {
log.Printf("Warning: failed to update sailor profile: %v", err)
} else {
log.Printf("✓ Updated sailor profile defaultHold: %s", publicURL)
}
}
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("REGISTRATION COMPLETE")
log.Print(strings.Repeat("=", 80))
log.Printf("Hold service is now registered and ready to use!")
log.Print(strings.Repeat("=", 80) + "\n")
return nil
}
// extractHostname extracts the hostname from a URL to use as the hold name
func extractHostname(urlStr string) (string, error) {
u, err := url.Parse(urlStr)
if err != nil {
return "", err
}
// Remove port if present
hostname := u.Hostname()
if hostname == "" {
return "", fmt.Errorf("no hostname in URL")
}
return hostname, nil
}
// ReconcileAllowAllCrew reconciles the allow-all crew record state with the environment variable
// Called on every startup to ensure the PDS record matches the desired configuration
func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error {
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
// No owner DID configured, skip reconciliation
return nil
}
desiredState := s.config.Registration.AllowAllCrew
log.Printf("Checking allow-all crew state (desired: %v)", desiredState)
// Query PDS for current state
actualState, err := s.hasAllowAllCrewRecord()
if err != nil {
return fmt.Errorf("failed to check allow-all crew record: %w", err)
}
log.Printf("Allow-all crew record exists: %v", actualState)
// States match - nothing to do
if desiredState == actualState {
if desiredState {
log.Printf("✓ Allow-all crew enabled (all authenticated users can push)")
} else {
log.Printf("✓ Allow-all crew disabled (explicit crew membership required)")
}
return nil
}
// State mismatch - need to reconcile
if desiredState && !actualState {
// Need to create wildcard crew record
log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)")
return s.createAllowAllCrewRecord(callbackHandler)
}
if !desiredState && actualState {
// Need to delete wildcard crew record
log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW=false)")
return s.deleteAllowAllCrewRecord(callbackHandler)
}
return nil
}
// hasAllowAllCrewRecord checks if the allow-all crew record exists in the PDS for THIS hold
func (s *HoldService) hasAllowAllCrewRecord() (bool, error) {
ownerDID := s.config.Registration.OwnerDID
publicURL := s.config.Server.PublicURL
if ownerDID == "" {
return false, fmt.Errorf("hold owner DID not configured")
}
if publicURL == "" {
return false, fmt.Errorf("hold public URL not configured")
}
ctx := context.Background()
// Resolve owner's PDS endpoint
directory := identity.DefaultDirectory()
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
if err != nil {
return false, fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
if err != nil {
return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return false, fmt.Errorf("no PDS endpoint found for owner")
}
// Build hold-specific rkey
holdName, err := extractHostname(publicURL)
if err != nil {
return false, fmt.Errorf("failed to extract hostname: %w", err)
}
crewRKey := fmt.Sprintf("allow-all-%s", holdName)
// Create unauthenticated client to read public records
client := atproto.NewClient(pdsEndpoint, ownerDID, "")
// Query for hold-specific allow-all record
record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, crewRKey)
if err != nil {
// Record doesn't exist
if errors.Is(err, atproto.ErrRecordNotFound) {
return false, nil
}
return false, fmt.Errorf("failed to get crew record: %w", err)
}
// Verify it's the wildcard record (memberPattern: "*")
var crewRecord atproto.HoldCrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
return false, fmt.Errorf("failed to unmarshal crew record: %w", err)
}
// Check if it's the exact wildcard pattern
if crewRecord.MemberPattern == nil || *crewRecord.MemberPattern != "*" {
return false, nil
}
// Verify it's for this hold (defensive check)
expectedHoldURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
return crewRecord.Hold == expectedHoldURI, nil
}
// createAllowAllCrewRecord creates a wildcard crew record allowing all authenticated users
func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
ownerDID := s.config.Registration.OwnerDID
publicURL := s.config.Server.PublicURL
// Run OAuth flow to get authenticated client
client, err := s.runOAuthFlow(callbackHandler, "Creating allow-all crew record")
if err != nil {
return err
}
ctx := context.Background()
// Get hold URI
holdName, err := extractHostname(publicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname: %w", err)
}
holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
// Create wildcard crew record
crewRecord := atproto.NewHoldCrewRecordWithPattern(holdURI, "*", "write")
// Use hold-specific rkey to support multiple holds with different allow-all settings
crewRKey := fmt.Sprintf("allow-all-%s", holdName)
_, err = client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
if err != nil {
return fmt.Errorf("failed to create allow-all crew record: %w", err)
}
log.Printf("✓ Created allow-all crew record (allows all authenticated users)")
return nil
}
// deleteAllowAllCrewRecord deletes the wildcard crew record for this hold
func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
// Safety check: only delete if it's the exact wildcard pattern for THIS hold
isWildcard, err := s.hasAllowAllCrewRecord()
if err != nil {
return fmt.Errorf("failed to check allow-all crew record: %w", err)
}
if !isWildcard {
log.Printf("Note: 'allow-all' crew record not found for this hold (may exist for other holds)")
return nil
}
// Get hold name for rkey
holdName, err := extractHostname(s.config.Server.PublicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname: %w", err)
}
crewRKey := fmt.Sprintf("allow-all-%s", holdName)
// Run OAuth flow to get authenticated client
client, err := s.runOAuthFlow(callbackHandler, "Deleting allow-all crew record")
if err != nil {
return err
}
ctx := context.Background()
// Delete the hold-specific allow-all record
err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, crewRKey)
if err != nil {
return fmt.Errorf("failed to delete allow-all crew record: %w", err)
}
log.Printf("✓ Deleted allow-all crew record for this hold")
return nil
}
// getHoldRegistrationScopes returns the OAuth scopes needed for hold registration and crew management
func getHoldRegistrationScopes() []string {
return []string{
"atproto",
fmt.Sprintf("repo:%s", atproto.HoldCollection),
fmt.Sprintf("repo:%s", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
}
}
// runOAuthFlow performs OAuth flow and returns an authenticated client
// Reusable helper to avoid code duplication across registration and reconciliation
func (s *HoldService) runOAuthFlow(callbackHandler *http.HandlerFunc, purpose string) (*atproto.Client, error) {
ownerDID := s.config.Registration.OwnerDID
publicURL := s.config.Server.PublicURL
ctx := context.Background()
// Resolve owner's PDS endpoint
directory := identity.DefaultDirectory()
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
if err != nil {
return nil, fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
if err != nil {
return nil, fmt.Errorf("failed to resolve owner PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return nil, fmt.Errorf("no PDS endpoint found for owner")
}
handle := ident.Handle.String()
if handle == "" || handle == "handle.invalid" {
return nil, fmt.Errorf("no valid handle found for DID")
}
// Determine base URL for OAuth
var baseURL string
if s.config.Server.TestMode {
parsedURL, err := url.Parse(publicURL)
if err != nil {
return nil, fmt.Errorf("failed to parse public URL: %w", err)
}
port := parsedURL.Port()
if port == "" {
port = "8080"
}
baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
} else {
baseURL = publicURL
}
// Run OAuth flow
result, err := oauth.InteractiveFlowWithCallback(
ctx,
baseURL,
handle,
getHoldRegistrationScopes(),
func(handler http.HandlerFunc) error {
*callbackHandler = handler
return nil
},
func(authURL string) error {
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("OAUTH REQUIRED: %s", purpose)
log.Print(strings.Repeat("=", 80))
log.Printf("\nVisit: %s\n", authURL)
log.Printf("Waiting for authorization...")
log.Print(strings.Repeat("=", 80) + "\n")
return nil
},
)
if err != nil {
return nil, fmt.Errorf("OAuth flow failed: %w", err)
}
// Create authenticated client
apiClient := result.Session.APIClient()
return atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient), nil
}
+22
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"github.com/aws/aws-sdk-go/service/s3"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
@@ -47,3 +49,23 @@ func NewHoldService(cfg *Config) (*HoldService, error) {
func (s *HoldService) GetPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
return s.getPresignedURL(ctx, operation, digest, did)
}
// HealthHandler handles health check requests
func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}
// extractHostname extracts the hostname from a URL
func extractHostname(urlStr string) (string, error) {
u, err := url.Parse(urlStr)
if err != nil {
return "", err
}
// Remove port if present
hostname := u.Hostname()
if hostname == "" {
return "", fmt.Errorf("no hostname in URL")
}
return hostname, nil
}