···11package main
2233import (
44+ "context"
45 "encoding/json"
56 "fmt"
67 "log"
···11121213 "atcr.io/pkg/atproto"
1314 "atcr.io/pkg/hold"
1515+ "atcr.io/pkg/hold/pds"
1416 indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
15171618 // Import storage drivers
···2931 service, err := hold.NewHoldService(cfg)
3032 if err != nil {
3133 log.Fatalf("Failed to create hold service: %v", err)
3434+ }
3535+3636+ // Initialize embedded PDS if database path is configured
3737+ var holdPDS *pds.HoldPDS
3838+ var xrpcHandler *pds.XRPCHandler
3939+ if cfg.Database.Path != "" {
4040+ // Generate did:web from public URL
4141+ holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL)
4242+ log.Printf("Initializing embedded PDS with DID: %s", holdDID)
4343+4444+ // Initialize PDS with carstore and keys
4545+ ctx := context.Background()
4646+ holdPDS, err = pds.NewHoldPDS(ctx, holdDID, cfg.Server.PublicURL, cfg.Database.Path, cfg.Database.KeyPath)
4747+ if err != nil {
4848+ log.Fatalf("Failed to initialize embedded PDS: %v", err)
4949+ }
5050+5151+ // Create blob store adapter
5252+ blobStore := pds.NewHoldServiceBlobStore(service, holdDID)
5353+5454+ // Create XRPC handler
5555+ xrpcHandler = pds.NewXRPCHandler(holdPDS, cfg.Server.PublicURL, blobStore)
5656+5757+ log.Printf("Embedded PDS initialized successfully")
3258 }
33593460 // Setup HTTP routes
···117143 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
118144 }
119145 })
146146+147147+ // Register XRPC/ATProto PDS endpoints if PDS is initialized
148148+ if xrpcHandler != nil {
149149+ log.Printf("Registering ATProto PDS endpoints")
150150+ xrpcHandler.RegisterHandlers(mux)
151151+ }
120152121153 // Create server
122154 server := &http.Server{
+26-2
deploy/.env.prod.template
···2929# Example: did:plc:abc123xyz789
3030HOLD_OWNER=did:plc:pddp4xt5lgnv2qsegbzzs4xg
31313232+# Directory path for embedded PDS carstore (SQLite database)
3333+# Default: /var/lib/atcr-hold
3434+# If empty, embedded PDS is disabled
3535+#
3636+# Note: This should be a directory path, NOT a file path
3737+# Carstore creates db.sqlite3 inside this directory
3838+#
3939+# The embedded PDS makes the hold a proper ATProto user with:
4040+# - did:web identity (derived from HOLD_DOMAIN)
4141+# - DID document at /.well-known/did.json
4242+# - XRPC endpoints for crew management
4343+# - ATProto blob endpoints (wraps existing presigned URL logic)
4444+#
4545+# Example: For HOLD_DOMAIN=hold01.atcr.io, the hold becomes did:web:hold01.atcr.io
4646+HOLD_DATABASE_DIR=/var/lib/atcr-hold
4747+4848+# Path to signing key (auto-generated on first run if missing)
4949+# Default: {HOLD_DATABASE_DIR}/signing.key
5050+# HOLD_KEY_PATH=/var/lib/atcr-hold/signing.key
5151+3252# Allow public blob reads (pulls) without authentication
3353# - true: Anyone can pull images (read-only)
3454# - false: Only authenticated users can pull
···799980100# S3 Region (for distribution S3 driver)
81101# UpCloud regions: us-chi1, us-nyc1, de-fra1, uk-lon1, sg-sin1, etc.
8282-# Default: us-chi1
8383-S3_REGION=us-chi1
102102+# Note: Use AWS_REGION (not S3_REGION) - this is what the hold service expects
103103+# Default: us-east-1
104104+AWS_REGION=us-chi1
8410585106# S3 Bucket Name
86107# Create this bucket in UpCloud Object Storage
···177198# ☐ Set APPVIEW_DOMAIN (e.g., atcr.io)
178199# ☐ Set HOLD_DOMAIN (e.g., hold01.atcr.io)
179200# ☐ Set HOLD_OWNER (your ATProto DID)
201201+# ☐ Set HOLD_DATABASE_DIR (default: /var/lib/atcr-hold) - enables embedded PDS
180202# ☐ Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
203203+# ☐ Set AWS_REGION (e.g., us-chi1)
181204# ☐ Set S3_BUCKET (created in UpCloud Object Storage)
182205# ☐ Set S3_ENDPOINT (UpCloud endpoint or custom domain)
183206# ☐ Configured DNS records:
···189212#
190213# After starting:
191214# ☐ Complete hold OAuth registration (run: /opt/atcr/get-hold-oauth.sh)
215215+# ☐ Verify hold PDS: curl https://hold01.atcr.io/.well-known/did.json
192216# ☐ Test registry: docker pull atcr.io/test/image
193217# ☐ Monitor logs: /opt/atcr/logs.sh
···510510511511## Implementation Plan
512512513513-### Phase 1: Basic PDS with Carstore (Current)
513513+### Phase 1: Basic PDS with Carstore ✅ COMPLETED
514514515515-**Decision: Use indigo's carstore with SQLite backend**
515515+**Implementation: Using indigo's carstore with SQLite + DeltaSession**
516516517517```go
518518import (
519519 "github.com/bluesky-social/indigo/carstore"
520520+ "github.com/bluesky-social/indigo/models"
520521 "github.com/bluesky-social/indigo/repo"
521522)
522523523524type HoldPDS struct {
524524- did string
525525- carstore carstore.CarStore
526526- repo *repo.Repo
525525+ did string
526526+ carstore carstore.CarStore
527527+ session *carstore.DeltaSession // Provides blockstore interface
528528+ repo *repo.Repo
529529+ dbPath string
530530+ uid models.Uid // User ID for carstore (fixed: 1)
527531}
528532529529-func NewHoldPDS(did, dbPath string) (*HoldPDS, error) {
533533+func NewHoldPDS(ctx context.Context, did, dbPath string) (*HoldPDS, error) {
530534 // Create SQLite-backed carstore
531535 sqlStore, err := carstore.NewSqliteStore(dbPath)
536536+ sqlStore.Open(dbPath)
532537 cs := sqlStore.CarStore()
533538534534- // Get or create repo
535535- head, err := cs.GetUserRepoHead(ctx, did)
536536- var r *repo.Repo
537537- if err == carstore.ErrRepoNotFound {
538538- r, err = repo.NewRepo(ctx, did, cs.Blockstore())
539539- } else {
540540- r, err = repo.OpenRepo(ctx, cs.Blockstore(), head)
541541- }
539539+ // For single-hold use, fixed UID
540540+ uid := models.Uid(1)
542541543543- return &HoldPDS{did: did, carstore: cs, repo: r}, nil
542542+ // Create DeltaSession (provides blockstore interface)
543543+ session, err := cs.NewDeltaSession(ctx, uid, nil)
544544+545545+ // Create repo with session as blockstore
546546+ r := repo.NewRepo(ctx, did, session)
547547+548548+ return &HoldPDS{
549549+ did: did,
550550+ carstore: cs,
551551+ session: session,
552552+ repo: r,
553553+ dbPath: dbPath,
554554+ uid: uid,
555555+ }, nil
544556}
545557```
546558559559+**Key learnings:**
560560+- ✅ Carstore provides blockstore via `DeltaSession` (not direct access)
561561+- ✅ `models.Uid` is the user ID type (we use fixed UID(1))
562562+- ✅ DeltaSession needs to be a pointer (`*carstore.DeltaSession`)
563563+- ✅ `repo.NewRepo()` accepts the session directly as blockstore
564564+547565**Storage:**
548566- Single file: `/var/lib/atcr-hold/hold.db` (SQLite)
549567- Contains MST nodes, records, commits in carstore tables
···552570**Why SQLite carstore:**
553571- ✅ Single file persistence (like appview's SQLite)
554572- ✅ Official indigo storage backend
555555-- ✅ No custom blockstore implementation needed
556573- ✅ Handles compaction/cleanup automatically
557574- ✅ Migration path to Postgres/Scylla if needed
558575- ✅ Easy to replicate (Litestream, LiteFS, rsync)
576576+- ✅ CAR import/export support built-in
559577560578**Scale considerations:**
561579- SQLite carstore marked "experimental" but suitable for single-hold use
···563581- 1000 crew records = ~1-2MB database (trivial)
564582- Bluesky PDSs use carstore for millions of records
565583- If needed: migrate to Postgres-backed carstore (same API)
584584+585585+### Hold as Proper ATProto User
586586+587587+**Decision:** Make holds full ATProto actors for discoverability and ecosystem integration.
588588+589589+**What this enables:**
590590+- Hold becomes discoverable via ATProto directory
591591+- Can have profile (`app.bsky.actor.profile`)
592592+- Can post status updates (`app.bsky.feed.post`)
593593+- Users can follow holds
594594+- Social proof/reputation via ATProto social graph
595595+596596+**MVP Scope:**
597597+We're building the minimal PDS needed for discoverability, not a full social client:
598598+- ✅ Signing keys (ES256K via `atproto/atcrypto`)
599599+- ✅ DID document (did:web at `/.well-known/did.json`)
600600+- ✅ Standard XRPC endpoints (`describeRepo`, `getRecord`, `listRecords`)
601601+- ✅ Profile record (`app.bsky.actor.profile`)
602602+- ⏸️ Posting functionality (later - other services can read our records)
603603+604604+**Key insight:** Other ATProto services will "just work" as long as they can retrieve records from the hold's PDS. We don't need to implement full social features for the hold to participate in the ecosystem.
566605567606### Crew Management: Individual Records
568607
···33import (
44 "fmt"
55 "os"
66+ "path/filepath"
67 "time"
7889 "github.com/distribution/distribution/v3/configuration"
···1415 Storage StorageConfig `yaml:"storage"`
1516 Server ServerConfig `yaml:"server"`
1617 Registration RegistrationConfig `yaml:"registration"`
1818+ Database DatabaseConfig `yaml:"database"`
1719}
18201921// RegistrationConfig defines auto-registration settings
···5759 WriteTimeout time.Duration `yaml:"write_timeout"`
5860}
59616262+// DatabaseConfig defines embedded PDS database settings
6363+type DatabaseConfig struct {
6464+ // Path is the directory path for carstore (from env: HOLD_DATABASE_DIR)
6565+ // If empty, embedded PDS is disabled
6666+ Path string `yaml:"path"`
6767+6868+ // KeyPath is the path to the signing key (from env: HOLD_KEY_PATH)
6969+ // Defaults to {Path}/signing.key
7070+ KeyPath string `yaml:"key_path"`
7171+}
7272+6073// LoadConfigFromEnv loads all configuration from environment variables
6174func LoadConfigFromEnv() (*Config, error) {
6275 cfg := &Config{
···7891 // Registration configuration (optional)
7992 cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER")
8093 cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true"
9494+9595+ // Database configuration (optional - enables embedded PDS)
9696+ // Note: HOLD_DATABASE_DIR is a directory path, carstore creates db.sqlite3 inside it
9797+ cfg.Database.Path = getEnvOrDefault("HOLD_DATABASE_DIR", "/var/lib/atcr-hold")
9898+ cfg.Database.KeyPath = os.Getenv("HOLD_KEY_PATH")
9999+ if cfg.Database.KeyPath == "" && cfg.Database.Path != "" {
100100+ // Default: signing key in same directory as carstore
101101+ cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
102102+ }
8110382104 // Storage configuration - build from env vars based on storage type
83105 storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
+44
pkg/hold/pds/blobstore_adapter.go
···11+package pds
22+33+import (
44+ "context"
55+66+ "atcr.io/pkg/hold"
77+)
88+99+// HoldServiceBlobStore adapts the hold service to implement the BlobStore interface
1010+type HoldServiceBlobStore struct {
1111+ service *hold.HoldService
1212+ holdDID string
1313+}
1414+1515+// NewHoldServiceBlobStore creates a blob store adapter for the hold service
1616+func NewHoldServiceBlobStore(service *hold.HoldService, holdDID string) *HoldServiceBlobStore {
1717+ return &HoldServiceBlobStore{
1818+ service: service,
1919+ holdDID: holdDID,
2020+ }
2121+}
2222+2323+// GetPresignedDownloadURL returns a presigned URL for downloading a blob
2424+func (b *HoldServiceBlobStore) GetPresignedDownloadURL(digest string) (string, error) {
2525+ // Use the hold service's existing presigned URL logic
2626+ // We need to expose a wrapper method on HoldService
2727+ ctx := context.Background()
2828+ url, err := b.service.GetPresignedURL(ctx, hold.OperationGet, digest, b.holdDID)
2929+ if err != nil {
3030+ return "", err
3131+ }
3232+ return url, nil
3333+}
3434+3535+// GetPresignedUploadURL returns a presigned URL for uploading a blob
3636+func (b *HoldServiceBlobStore) GetPresignedUploadURL(digest string) (string, error) {
3737+ // Use the hold service's existing presigned URL logic
3838+ ctx := context.Background()
3939+ url, err := b.service.GetPresignedURL(ctx, hold.OperationPut, digest, b.holdDID)
4040+ if err != nil {
4141+ return "", err
4242+ }
4343+ return url, nil
4444+}
+113
pkg/hold/pds/crew.go
···11+package pds
22+33+import (
44+ "context"
55+ "fmt"
66+ "io"
77+ "time"
88+99+ "github.com/ipfs/go-cid"
1010+)
1111+1212+// CrewRecord represents a crew member in the hold
1313+type CrewRecord struct {
1414+ Member string `json:"member" cborgen:"member"` // DID of the crew member
1515+ Role string `json:"role" cborgen:"role"` // "admin" or "member"
1616+ Permissions []string `json:"permissions" cborgen:"permissions"` // e.g., ["blob:read", "blob:write"]
1717+ AddedAt time.Time `json:"addedAt" cborgen:"addedAt"`
1818+}
1919+2020+// MarshalCBOR implements cbg.CBORMarshaler
2121+func (c *CrewRecord) MarshalCBOR(w io.Writer) error {
2222+ // TODO: Implement proper CBOR marshaling
2323+ return fmt.Errorf("CBOR marshaling not yet implemented")
2424+}
2525+2626+// UnmarshalCBOR implements cbg.CBORUnmarshaler
2727+func (c *CrewRecord) UnmarshalCBOR(r io.Reader) error {
2828+ // TODO: Implement proper CBOR unmarshaling
2929+ return fmt.Errorf("CBOR unmarshaling not yet implemented")
3030+}
3131+3232+const (
3333+ CrewCollection = "io.atcr.hold.crew"
3434+)
3535+3636+// AddCrewMember adds a new crew member to the hold
3737+func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
3838+ crewRecord := &CrewRecord{
3939+ Member: memberDID,
4040+ Role: role,
4141+ Permissions: permissions,
4242+ AddedAt: time.Now(),
4343+ }
4444+4545+ // Create record in repo
4646+ recordCID, rkey, err := p.repo.CreateRecord(ctx, CrewCollection, crewRecord)
4747+ if err != nil {
4848+ return cid.Undef, fmt.Errorf("failed to create crew record: %w", err)
4949+ }
5050+5151+ // TODO: Commit the changes
5252+ // For now, just return the CID
5353+ _ = rkey // We'll use rkey for GetCrewMember later
5454+5555+ return recordCID, nil
5656+}
5757+5858+// GetCrewMember retrieves a crew member by their record key
5959+func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (*CrewRecord, error) {
6060+ path := fmt.Sprintf("%s/%s", CrewCollection, rkey)
6161+6262+ _, rec, err := p.repo.GetRecord(ctx, path)
6363+ if err != nil {
6464+ return nil, fmt.Errorf("failed to get crew record: %w", err)
6565+ }
6666+6767+ crewRecord, ok := rec.(*CrewRecord)
6868+ if !ok {
6969+ return nil, fmt.Errorf("record is not a CrewRecord")
7070+ }
7171+7272+ return crewRecord, nil
7373+}
7474+7575+// ListCrewMembers returns all crew members
7676+func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewRecord, error) {
7777+ var crew []*CrewRecord
7878+7979+ err := p.repo.ForEach(ctx, CrewCollection, func(k string, v cid.Cid) error {
8080+ // Get the full record
8181+ path := fmt.Sprintf("%s/%s", CrewCollection, k)
8282+ _, rec, err := p.repo.GetRecord(ctx, path)
8383+ if err != nil {
8484+ return err
8585+ }
8686+8787+ if crewRecord, ok := rec.(*CrewRecord); ok {
8888+ crew = append(crew, crewRecord)
8989+ }
9090+9191+ return nil
9292+ })
9393+9494+ if err != nil {
9595+ return nil, fmt.Errorf("failed to list crew members: %w", err)
9696+ }
9797+9898+ return crew, nil
9999+}
100100+101101+// RemoveCrewMember removes a crew member
102102+func (p *HoldPDS) RemoveCrewMember(ctx context.Context, rkey string) error {
103103+ path := fmt.Sprintf("%s/%s", CrewCollection, rkey)
104104+105105+ err := p.repo.DeleteRecord(ctx, path)
106106+ if err != nil {
107107+ return fmt.Errorf("failed to delete crew record: %w", err)
108108+ }
109109+110110+ // TODO: Commit the changes
111111+112112+ return nil
113113+}
+150
pkg/hold/pds/did.go
···11+package pds
22+33+import (
44+ "crypto/ecdsa"
55+ "crypto/elliptic"
66+ "encoding/base64"
77+ "encoding/json"
88+ "fmt"
99+ "net/url"
1010+ "strings"
1111+)
1212+1313+// DIDDocument represents a did:web document
1414+type DIDDocument struct {
1515+ Context []string `json:"@context"`
1616+ ID string `json:"id"`
1717+ AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
1818+ VerificationMethod []VerificationMethod `json:"verificationMethod"`
1919+ Authentication []string `json:"authentication,omitempty"`
2020+ AssertionMethod []string `json:"assertionMethod,omitempty"`
2121+ Service []Service `json:"service,omitempty"`
2222+}
2323+2424+// VerificationMethod represents a public key in a DID document
2525+type VerificationMethod struct {
2626+ ID string `json:"id"`
2727+ Type string `json:"type"`
2828+ Controller string `json:"controller"`
2929+ PublicKeyMultibase string `json:"publicKeyMultibase"`
3030+}
3131+3232+// Service represents a service endpoint in a DID document
3333+type Service struct {
3434+ ID string `json:"id"`
3535+ Type string `json:"type"`
3636+ ServiceEndpoint string `json:"serviceEndpoint"`
3737+}
3838+3939+// GenerateDIDDocument creates a DID document for a did:web identity
4040+func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
4141+ // Extract hostname from public URL
4242+ hostname := strings.TrimPrefix(publicURL, "http://")
4343+ hostname = strings.TrimPrefix(hostname, "https://")
4444+ hostname = strings.Split(hostname, "/")[0] // Remove any path
4545+ hostname = strings.Split(hostname, ":")[0] // Remove port for DID
4646+4747+ did := fmt.Sprintf("did:web:%s", hostname)
4848+4949+ // Convert public key to multibase format
5050+ publicKeyMultibase, err := encodePublicKeyMultibase(p.signingKey.PublicKey)
5151+ if err != nil {
5252+ return nil, fmt.Errorf("failed to encode public key: %w", err)
5353+ }
5454+5555+ doc := &DIDDocument{
5656+ Context: []string{
5757+ "https://www.w3.org/ns/did/v1",
5858+ "https://w3id.org/security/multikey/v1",
5959+ },
6060+ ID: did,
6161+ VerificationMethod: []VerificationMethod{
6262+ {
6363+ ID: fmt.Sprintf("%s#atproto", did),
6464+ Type: "Multikey",
6565+ Controller: did,
6666+ PublicKeyMultibase: publicKeyMultibase,
6767+ },
6868+ },
6969+ Authentication: []string{
7070+ fmt.Sprintf("%s#atproto", did),
7171+ },
7272+ Service: []Service{
7373+ {
7474+ ID: "#atproto_pds",
7575+ Type: "AtprotoPersonalDataServer",
7676+ ServiceEndpoint: publicURL,
7777+ },
7878+ },
7979+ }
8080+8181+ return doc, nil
8282+}
8383+8484+// encodePublicKeyMultibase encodes an ECDSA public key in multibase format
8585+// For P-256 keys, we use the compressed format with multicodec prefix
8686+func encodePublicKeyMultibase(pubKey ecdsa.PublicKey) (string, error) {
8787+ // Check if this is a P-256 key
8888+ if pubKey.Curve != elliptic.P256() {
8989+ return "", fmt.Errorf("unsupported curve: only P-256 is supported")
9090+ }
9191+9292+ // Use compressed point format
9393+ // 0x02 if Y is even, 0x03 if Y is odd
9494+ var prefix byte
9595+ if pubKey.Y.Bit(0) == 0 {
9696+ prefix = 0x02
9797+ } else {
9898+ prefix = 0x03
9999+ }
100100+101101+ // Compressed format: prefix (1 byte) + X coordinate (32 bytes for P-256)
102102+ xBytes := pubKey.X.Bytes()
103103+104104+ // Pad X to 32 bytes if needed
105105+ paddedX := make([]byte, 32)
106106+ copy(paddedX[32-len(xBytes):], xBytes)
107107+108108+ compressed := append([]byte{prefix}, paddedX...)
109109+110110+ // Multicodec prefix for P-256 public key: 0x1200
111111+ // See https://github.com/multiformats/multicodec/blob/master/table.csv
112112+ multicodec := []byte{0x80, 0x24} // P-256 public key multicodec
113113+114114+ // Combine multicodec + compressed key
115115+ multicodecKey := append(multicodec, compressed...)
116116+117117+ // Encode in multibase (base58btc, prefix 'z')
118118+ encoded := base64.RawURLEncoding.EncodeToString(multicodecKey)
119119+120120+ return "z" + encoded, nil
121121+}
122122+123123+// MarshalDIDDocument converts a DID document to JSON using the stored public URL
124124+func (p *HoldPDS) MarshalDIDDocument() ([]byte, error) {
125125+ doc, err := p.GenerateDIDDocument(p.publicURL)
126126+ if err != nil {
127127+ return nil, err
128128+ }
129129+130130+ return json.MarshalIndent(doc, "", " ")
131131+}
132132+133133+// GenerateDIDFromURL creates a did:web identifier from a public URL
134134+// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com"
135135+func GenerateDIDFromURL(publicURL string) string {
136136+ // Parse URL
137137+ u, err := url.Parse(publicURL)
138138+ if err != nil {
139139+ // Fallback: assume it's just a hostname
140140+ return fmt.Sprintf("did:web:%s", strings.Split(publicURL, ":")[0])
141141+ }
142142+143143+ // Use hostname without port for DID
144144+ hostname := u.Hostname()
145145+ if hostname == "" {
146146+ hostname = "localhost"
147147+ }
148148+149149+ return fmt.Sprintf("did:web:%s", hostname)
150150+}
+102
pkg/hold/pds/keys.go
···11+package pds
22+33+import (
44+ "crypto/ecdsa"
55+ "crypto/elliptic"
66+ "crypto/rand"
77+ "crypto/x509"
88+ "encoding/pem"
99+ "fmt"
1010+ "os"
1111+ "path/filepath"
1212+)
1313+1414+// GenerateOrLoadKey generates a new K256 key pair or loads an existing one
1515+func GenerateOrLoadKey(keyPath string) (*ecdsa.PrivateKey, error) {
1616+ // Ensure directory exists
1717+ dir := filepath.Dir(keyPath)
1818+ if err := os.MkdirAll(dir, 0700); err != nil {
1919+ return nil, fmt.Errorf("failed to create key directory: %w", err)
2020+ }
2121+2222+ // Check if key already exists
2323+ if _, err := os.Stat(keyPath); err == nil {
2424+ // Key exists, load it
2525+ return loadKey(keyPath)
2626+ }
2727+2828+ // Key doesn't exist, generate new one
2929+ return generateKey(keyPath)
3030+}
3131+3232+// generateKey creates a new K256 (secp256k1) key pair
3333+func generateKey(keyPath string) (*ecdsa.PrivateKey, error) {
3434+ // Generate K256 key (secp256k1)
3535+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
3636+ if err != nil {
3737+ return nil, fmt.Errorf("failed to generate key: %w", err)
3838+ }
3939+4040+ // Marshal private key to DER format
4141+ derBytes, err := x509.MarshalECPrivateKey(privateKey)
4242+ if err != nil {
4343+ return nil, fmt.Errorf("failed to marshal private key: %w", err)
4444+ }
4545+4646+ // Create PEM block
4747+ pemBlock := &pem.Block{
4848+ Type: "EC PRIVATE KEY",
4949+ Bytes: derBytes,
5050+ }
5151+5252+ // Write to file with restrictive permissions
5353+ file, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
5454+ if err != nil {
5555+ return nil, fmt.Errorf("failed to create key file: %w", err)
5656+ }
5757+ defer file.Close()
5858+5959+ if err := pem.Encode(file, pemBlock); err != nil {
6060+ return nil, fmt.Errorf("failed to write PEM data: %w", err)
6161+ }
6262+6363+ fmt.Printf("Generated new signing key at %s\n", keyPath)
6464+ return privateKey, nil
6565+}
6666+6767+// loadKey loads an existing private key from disk
6868+func loadKey(keyPath string) (*ecdsa.PrivateKey, error) {
6969+ // Read PEM file
7070+ pemData, err := os.ReadFile(keyPath)
7171+ if err != nil {
7272+ return nil, fmt.Errorf("failed to read key file: %w", err)
7373+ }
7474+7575+ // Decode PEM block
7676+ block, _ := pem.Decode(pemData)
7777+ if block == nil {
7878+ return nil, fmt.Errorf("failed to decode PEM block")
7979+ }
8080+8181+ // Parse EC private key
8282+ privateKey, err := x509.ParseECPrivateKey(block.Bytes)
8383+ if err != nil {
8484+ return nil, fmt.Errorf("failed to parse private key: %w", err)
8585+ }
8686+8787+ fmt.Printf("Loaded existing signing key from %s\n", keyPath)
8888+ return privateKey, nil
8989+}
9090+9191+// PublicKeyToBase58 converts an ECDSA public key to base58 format for DID documents
9292+func PublicKeyToBase58(pubKey *ecdsa.PublicKey) (string, error) {
9393+ // Marshal public key to X.509 SPKI format
9494+ derBytes, err := x509.MarshalPKIXPublicKey(pubKey)
9595+ if err != nil {
9696+ return "", fmt.Errorf("failed to marshal public key: %w", err)
9797+ }
9898+9999+ // TODO: Convert to base58 (need to import base58 library)
100100+ // For now, just return hex encoding as placeholder
101101+ return fmt.Sprintf("%x", derBytes), nil
102102+}
+105
pkg/hold/pds/server.go
···11+package pds
22+33+import (
44+ "context"
55+ "crypto/ecdsa"
66+ "fmt"
77+ "os"
88+ "path/filepath"
99+1010+ "github.com/bluesky-social/indigo/carstore"
1111+ "github.com/bluesky-social/indigo/models"
1212+ "github.com/bluesky-social/indigo/repo"
1313+)
1414+1515+// HoldPDS is a minimal ATProto PDS implementation for a hold service
1616+type HoldPDS struct {
1717+ did string
1818+ publicURL string
1919+ carstore carstore.CarStore
2020+ session *carstore.DeltaSession
2121+ repo *repo.Repo
2222+ dbPath string
2323+ uid models.Uid
2424+ signingKey *ecdsa.PrivateKey
2525+}
2626+2727+// NewHoldPDS creates or opens a hold PDS with SQLite carstore
2828+func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string) (*HoldPDS, error) {
2929+ // Ensure directory exists
3030+ dir := filepath.Dir(dbPath)
3131+ if err := os.MkdirAll(dir, 0755); err != nil {
3232+ return nil, fmt.Errorf("failed to create database directory: %w", err)
3333+ }
3434+3535+ // Generate or load signing key
3636+ signingKey, err := GenerateOrLoadKey(keyPath)
3737+ if err != nil {
3838+ return nil, fmt.Errorf("failed to initialize signing key: %w", err)
3939+ }
4040+4141+ // Create and open SQLite-backed carstore
4242+ // dbPath is the directory, carstore creates and opens db.sqlite3 inside it
4343+ sqlStore, err := carstore.NewSqliteStore(dbPath)
4444+ if err != nil {
4545+ return nil, fmt.Errorf("failed to create sqlite store: %w", err)
4646+ }
4747+4848+ cs := sqlStore.CarStore()
4949+5050+ // For a single-user hold, we use a fixed UID (1)
5151+ uid := models.Uid(1)
5252+5353+ // Try to get existing repo head
5454+ _, err = cs.GetUserRepoHead(ctx, uid)
5555+5656+ var session *carstore.DeltaSession
5757+ var r *repo.Repo
5858+5959+ if err != nil {
6060+ // Repo doesn't exist yet, create new delta session
6161+ session, err = cs.NewDeltaSession(ctx, uid, nil)
6262+ if err != nil {
6363+ return nil, fmt.Errorf("failed to create delta session: %w", err)
6464+ }
6565+6666+ // Create new repo with session as blockstore (needs pointer)
6767+ r = repo.NewRepo(ctx, did, session)
6868+ } else {
6969+ // TODO: Load existing repo
7070+ // For now, just create a new session
7171+ session, err = cs.NewDeltaSession(ctx, uid, nil)
7272+ if err != nil {
7373+ return nil, fmt.Errorf("failed to create delta session: %w", err)
7474+ }
7575+7676+ r = repo.NewRepo(ctx, did, session)
7777+ }
7878+7979+ return &HoldPDS{
8080+ did: did,
8181+ publicURL: publicURL,
8282+ carstore: cs,
8383+ session: session,
8484+ repo: r,
8585+ dbPath: dbPath,
8686+ uid: uid,
8787+ signingKey: signingKey,
8888+ }, nil
8989+}
9090+9191+// DID returns the hold's DID
9292+func (p *HoldPDS) DID() string {
9393+ return p.did
9494+}
9595+9696+// SigningKey returns the hold's signing key
9797+func (p *HoldPDS) SigningKey() *ecdsa.PrivateKey {
9898+ return p.signingKey
9999+}
100100+101101+// Close closes the session and carstore
102102+func (p *HoldPDS) Close() error {
103103+ // TODO: Close session properly
104104+ return nil
105105+}
+273
pkg/hold/pds/xrpc.go
···11+package pds
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "net/http"
77+)
88+99+// XRPC handler for ATProto endpoints
1010+1111+// XRPCHandler handles XRPC requests for the embedded PDS
1212+type XRPCHandler struct {
1313+ pds *HoldPDS
1414+ publicURL string
1515+ blobStore BlobStore
1616+}
1717+1818+// BlobStore interface wraps the existing hold service storage operations
1919+type BlobStore interface {
2020+ // GetPresignedDownloadURL returns a presigned URL for downloading a blob
2121+ GetPresignedDownloadURL(digest string) (string, error)
2222+ // GetPresignedUploadURL returns a presigned URL for uploading a blob
2323+ GetPresignedUploadURL(digest string) (string, error)
2424+}
2525+2626+// NewXRPCHandler creates a new XRPC handler
2727+func NewXRPCHandler(pds *HoldPDS, publicURL string, blobStore BlobStore) *XRPCHandler {
2828+ return &XRPCHandler{
2929+ pds: pds,
3030+ publicURL: publicURL,
3131+ blobStore: blobStore,
3232+ }
3333+}
3434+3535+// RegisterHandlers registers all XRPC endpoints
3636+func (h *XRPCHandler) RegisterHandlers(mux *http.ServeMux) {
3737+ // Standard PDS endpoints
3838+ mux.HandleFunc("/xrpc/com.atproto.server.describeServer", h.HandleDescribeServer)
3939+ mux.HandleFunc("/xrpc/com.atproto.repo.describeRepo", h.HandleDescribeRepo)
4040+ mux.HandleFunc("/xrpc/com.atproto.repo.getRecord", h.HandleGetRecord)
4141+ mux.HandleFunc("/xrpc/com.atproto.repo.listRecords", h.HandleListRecords)
4242+4343+ // Blob endpoints (wrap existing presigned URL logic)
4444+ mux.HandleFunc("/xrpc/com.atproto.repo.uploadBlob", h.HandleUploadBlob)
4545+ mux.HandleFunc("/xrpc/com.atproto.sync.getBlob", h.HandleGetBlob)
4646+4747+ // DID document
4848+ mux.HandleFunc("/.well-known/did.json", h.HandleDIDDocument)
4949+}
5050+5151+// HandleDescribeServer returns server metadata
5252+func (h *XRPCHandler) HandleDescribeServer(w http.ResponseWriter, r *http.Request) {
5353+ if r.Method != http.MethodGet {
5454+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
5555+ return
5656+ }
5757+5858+ response := map[string]interface{}{
5959+ "did": h.pds.DID(),
6060+ "availableUserDomains": []string{},
6161+ "inviteCodeRequired": false,
6262+ "links": map[string]string{},
6363+ }
6464+6565+ w.Header().Set("Content-Type", "application/json")
6666+ json.NewEncoder(w).Encode(response)
6767+}
6868+6969+// HandleDescribeRepo returns repository information
7070+func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) {
7171+ if r.Method != http.MethodGet {
7272+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
7373+ return
7474+ }
7575+7676+ // Get repo parameter
7777+ repo := r.URL.Query().Get("repo")
7878+ if repo == "" || repo != h.pds.DID() {
7979+ http.Error(w, "invalid repo", http.StatusBadRequest)
8080+ return
8181+ }
8282+8383+ // Generate DID document
8484+ didDoc, err := h.pds.GenerateDIDDocument(h.publicURL)
8585+ if err != nil {
8686+ http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
8787+ return
8888+ }
8989+9090+ // Extract handle from did:web (remove "did:web:" prefix)
9191+ handle := h.pds.DID()
9292+ if len(handle) > 8 && handle[:8] == "did:web:" {
9393+ handle = handle[8:] // "did:web:example.com" -> "example.com"
9494+ }
9595+9696+ // TODO: Get actual repo head from carstore
9797+ response := map[string]interface{}{
9898+ "did": h.pds.DID(),
9999+ "handle": handle,
100100+ "didDoc": didDoc,
101101+ "collections": []string{CrewCollection},
102102+ "handleIsCorrect": true,
103103+ }
104104+105105+ w.Header().Set("Content-Type", "application/json")
106106+ json.NewEncoder(w).Encode(response)
107107+}
108108+109109+// HandleGetRecord retrieves a record from the repository
110110+func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) {
111111+ if r.Method != http.MethodGet {
112112+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
113113+ return
114114+ }
115115+116116+ repo := r.URL.Query().Get("repo")
117117+ collection := r.URL.Query().Get("collection")
118118+ rkey := r.URL.Query().Get("rkey")
119119+120120+ if repo == "" || collection == "" || rkey == "" {
121121+ http.Error(w, "missing required parameters", http.StatusBadRequest)
122122+ return
123123+ }
124124+125125+ if repo != h.pds.DID() {
126126+ http.Error(w, "invalid repo", http.StatusBadRequest)
127127+ return
128128+ }
129129+130130+ // Only support crew collection for now
131131+ if collection != CrewCollection {
132132+ http.Error(w, "collection not found", http.StatusNotFound)
133133+ return
134134+ }
135135+136136+ crewRecord, err := h.pds.GetCrewMember(r.Context(), rkey)
137137+ if err != nil {
138138+ http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusNotFound)
139139+ return
140140+ }
141141+142142+ response := map[string]interface{}{
143143+ "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey),
144144+ "value": crewRecord,
145145+ }
146146+147147+ w.Header().Set("Content-Type", "application/json")
148148+ json.NewEncoder(w).Encode(response)
149149+}
150150+151151+// HandleListRecords lists records in a collection
152152+func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request) {
153153+ if r.Method != http.MethodGet {
154154+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
155155+ return
156156+ }
157157+158158+ repo := r.URL.Query().Get("repo")
159159+ collection := r.URL.Query().Get("collection")
160160+161161+ if repo == "" || collection == "" {
162162+ http.Error(w, "missing required parameters", http.StatusBadRequest)
163163+ return
164164+ }
165165+166166+ if repo != h.pds.DID() {
167167+ http.Error(w, "invalid repo", http.StatusBadRequest)
168168+ return
169169+ }
170170+171171+ // Only support crew collection for now
172172+ if collection != CrewCollection {
173173+ http.Error(w, "collection not found", http.StatusNotFound)
174174+ return
175175+ }
176176+177177+ crew, err := h.pds.ListCrewMembers(r.Context())
178178+ if err != nil {
179179+ http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
180180+ return
181181+ }
182182+183183+ records := make([]map[string]interface{}, len(crew))
184184+ for i, member := range crew {
185185+ // TODO: Get actual rkey from somewhere
186186+ records[i] = map[string]interface{}{
187187+ "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, member.Member),
188188+ "value": member,
189189+ }
190190+ }
191191+192192+ response := map[string]interface{}{
193193+ "records": records,
194194+ }
195195+196196+ w.Header().Set("Content-Type", "application/json")
197197+ json.NewEncoder(w).Encode(response)
198198+}
199199+200200+// HandleUploadBlob wraps existing presigned upload URL logic
201201+func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
202202+ if r.Method != http.MethodPost {
203203+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
204204+ return
205205+ }
206206+207207+ // TODO: Authentication check
208208+209209+ // Read digest from query or calculate from body
210210+ digest := r.URL.Query().Get("digest")
211211+ if digest == "" {
212212+ http.Error(w, "digest required", http.StatusBadRequest)
213213+ return
214214+ }
215215+216216+ // Get presigned upload URL from existing blob store
217217+ uploadURL, err := h.blobStore.GetPresignedUploadURL(digest)
218218+ if err != nil {
219219+ http.Error(w, fmt.Sprintf("failed to get upload URL: %v", err), http.StatusInternalServerError)
220220+ return
221221+ }
222222+223223+ // Return 302 redirect to presigned URL
224224+ http.Redirect(w, r, uploadURL, http.StatusFound)
225225+}
226226+227227+// HandleGetBlob wraps existing presigned download URL logic
228228+func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
229229+ if r.Method != http.MethodGet {
230230+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
231231+ return
232232+ }
233233+234234+ did := r.URL.Query().Get("did")
235235+ digest := r.URL.Query().Get("cid")
236236+237237+ if did == "" || digest == "" {
238238+ http.Error(w, "missing required parameters", http.StatusBadRequest)
239239+ return
240240+ }
241241+242242+ if did != h.pds.DID() {
243243+ http.Error(w, "invalid did", http.StatusBadRequest)
244244+ return
245245+ }
246246+247247+ // Get presigned download URL from existing blob store
248248+ downloadURL, err := h.blobStore.GetPresignedDownloadURL(digest)
249249+ if err != nil {
250250+ http.Error(w, fmt.Sprintf("failed to get download URL: %v", err), http.StatusInternalServerError)
251251+ return
252252+ }
253253+254254+ // Return 302 redirect to presigned URL
255255+ http.Redirect(w, r, downloadURL, http.StatusFound)
256256+}
257257+258258+// HandleDIDDocument returns the DID document
259259+func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request) {
260260+ if r.Method != http.MethodGet {
261261+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
262262+ return
263263+ }
264264+265265+ doc, err := h.pds.GenerateDIDDocument(h.publicURL)
266266+ if err != nil {
267267+ http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
268268+ return
269269+ }
270270+271271+ w.Header().Set("Content-Type", "application/did+json")
272272+ json.NewEncoder(w).Encode(doc)
273273+}
+5
pkg/hold/service.go
···42424343 return service, nil
4444}
4545+4646+// GetPresignedURL is a public wrapper around getPresignedURL for use by PDS blob store
4747+func (s *HoldService) GetPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
4848+ return s.getPresignedURL(ctx, operation, digest, did)
4949+}