···5353export HOLD_OWNER=did:plc:your-did-here
5454./bin/atcr-hold
5555# Hold starts immediately with embedded PDS
5656+5757+# Request Bluesky relay crawl (makes your PDS discoverable)
5858+./deploy/request-crawl.sh hold01.atcr.io
5959+# Or specify a different relay:
6060+./deploy/request-crawl.sh hold01.atcr.io https://custom-relay.example.com/xrpc/com.atproto.sync.requestCrawl
5661```
57625863## Architecture Overview
···370375- `PUT /blobs/{digest}` - Proxy upload (fallback)
371376- `POST /register` - Manual registration endpoint
372377- `GET /health` - Health check
378378+379379+**Embedded PDS Endpoints:**
380380+381381+Each hold service includes an embedded PDS (Personal Data Server) that stores captain + crew records:
382382+383383+- `GET /xrpc/com.atproto.sync.getRepo?did={did}` - Download full repository as CAR file
384384+- `GET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev}` - Download repository diff since revision
385385+- `GET /xrpc/com.atproto.sync.subscribeRepos` - WebSocket firehose for real-time events
386386+- `GET /xrpc/com.atproto.sync.listRepos` - List all repositories (single-user PDS)
387387+- `GET /.well-known/did.json` - DID document (did:web resolution)
388388+- Standard ATProto repo endpoints (getRecord, listRecords, etc.)
389389+390390+The `subscribeRepos` endpoint broadcasts #commit events whenever crew membership changes, allowing AppViews to monitor hold access control in real-time.
373391374392**Configuration:** Environment variables (see `.env.example`)
375393- `HOLD_PUBLIC_URL` - Public URL of hold service (required)
+9-2
cmd/hold/main.go
···2727 // This must happen before creating HoldService since service needs PDS for authorization
2828 var holdPDS *pds.HoldPDS
2929 var xrpcHandler *pds.XRPCHandler
3030+ var broadcaster *pds.EventBroadcaster
3031 if cfg.Database.Path != "" {
3132 // Generate did:web from public URL
3233 holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL)
···4445 log.Fatalf("Failed to bootstrap PDS: %v", err)
4546 }
46474747- log.Printf("Embedded PDS initialized successfully")
4848+ // Create event broadcaster for subscribeRepos firehose
4949+ broadcaster = pds.NewEventBroadcaster(holdDID, 100) // Keep 100 events for backfill
5050+5151+ // Wire up repo event handler to broadcaster
5252+ holdPDS.RepomgrRef().SetEventHandler(broadcaster.SetRepoEventHandler(), true)
5353+5454+ log.Printf("Embedded PDS initialized successfully with firehose enabled")
4855 } else {
4956 log.Fatalf("Database path is required for embedded PDS authorization")
5057 }
···5966 if holdPDS != nil {
6067 holdDID := holdPDS.DID()
6168 blobStore := hold.NewHoldServiceBlobStore(service, holdDID)
6262- xrpcHandler = pds.NewXRPCHandler(holdPDS, cfg.Server.PublicURL, blobStore)
6969+ xrpcHandler = pds.NewXRPCHandler(holdPDS, cfg.Server.PublicURL, blobStore, broadcaster)
6370 }
64716572 // Setup HTTP routes
+55
deploy/request-crawl.sh
···11+#!/bin/bash
22+#
33+# Request crawl for a PDS from the Bluesky relay
44+#
55+# Usage: ./request-crawl.sh <hostname> [relay-url]
66+# Example: ./request-crawl.sh hold01.atcr.io
77+#
88+99+set -e
1010+1111+DEFAULT_RELAY="https://bsky.network/xrpc/com.atproto.sync.requestCrawl"
1212+1313+# Parse arguments
1414+HOSTNAME="${1:-}"
1515+RELAY_URL="${2:-$DEFAULT_RELAY}"
1616+1717+# Validate hostname
1818+if [ -z "$HOSTNAME" ]; then
1919+ echo "Error: hostname is required" >&2
2020+ echo "" >&2
2121+ echo "Usage: $0 <hostname> [relay-url]" >&2
2222+ echo "Example: $0 hold01.atcr.io" >&2
2323+ echo "" >&2
2424+ echo "Options:" >&2
2525+ echo " hostname Hostname of the PDS to request crawl for (required)" >&2
2626+ echo " relay-url Relay URL to send crawl request to (default: $DEFAULT_RELAY)" >&2
2727+ exit 1
2828+fi
2929+3030+# Log what we're doing
3131+echo "Requesting crawl for hostname: $HOSTNAME"
3232+echo "Sending to relay: $RELAY_URL"
3333+3434+# Make the request
3535+RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$RELAY_URL" \
3636+ -H "Content-Type: application/json" \
3737+ -d "{\"hostname\":\"$HOSTNAME\"}")
3838+3939+# Split response and status code
4040+HTTP_BODY=$(echo "$RESPONSE" | head -n -1)
4141+HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
4242+4343+# Check response
4444+if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
4545+ echo "✅ Success! Crawl requested for $HOSTNAME"
4646+ if [ -n "$HTTP_BODY" ]; then
4747+ echo "Response: $HTTP_BODY"
4848+ fi
4949+else
5050+ echo "❌ Failed with status $HTTP_CODE" >&2
5151+ if [ -n "$HTTP_BODY" ]; then
5252+ echo "Response: $HTTP_BODY" >&2
5353+ fi
5454+ exit 1
5555+fi
+252
pkg/hold/pds/events.go
···11+package pds
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "log"
77+ "sync"
88+ "time"
99+1010+ atproto "github.com/bluesky-social/indigo/api/atproto"
1111+ lexutil "github.com/bluesky-social/indigo/lex/util"
1212+ "github.com/gorilla/websocket"
1313+)
1414+1515+// EventBroadcaster manages WebSocket connections and broadcasts repo events
1616+type EventBroadcaster struct {
1717+ mu sync.RWMutex
1818+ subscribers map[*Subscriber]bool
1919+ eventSeq int64
2020+ eventHistory []HistoricalEvent // Ring buffer for cursor backfill
2121+ maxHistory int
2222+ holdDID string // DID of the hold for setting repo field
2323+}
2424+2525+// Subscriber represents a WebSocket client subscribed to the firehose
2626+type Subscriber struct {
2727+ conn *websocket.Conn
2828+ send chan *RepoCommitEvent
2929+ cursor int64 // Last sequence number this subscriber has seen
3030+}
3131+3232+// HistoricalEvent stores past events for cursor-based backfill
3333+type HistoricalEvent struct {
3434+ Seq int64
3535+ Event *RepoCommitEvent
3636+}
3737+3838+// RepoCommitEvent represents a #commit event in subscribeRepos
3939+type RepoCommitEvent struct {
4040+ Seq int64 `json:"seq" cborgen:"seq"`
4141+ Repo string `json:"repo" cborgen:"repo"`
4242+ Commit string `json:"commit" cborgen:"commit"` // CID string
4343+ Rev string `json:"rev" cborgen:"rev"`
4444+ Since *string `json:"since,omitempty" cborgen:"since,omitempty"`
4545+ Blocks []byte `json:"blocks" cborgen:"blocks"` // CAR slice bytes
4646+ Ops []*atproto.SyncSubscribeRepos_RepoOp `json:"ops" cborgen:"ops"`
4747+ Time string `json:"time" cborgen:"time"`
4848+ Type string `json:"$type" cborgen:"$type"` // Always "#commit"
4949+}
5050+5151+// NewEventBroadcaster creates a new event broadcaster
5252+func NewEventBroadcaster(holdDID string, maxHistory int) *EventBroadcaster {
5353+ if maxHistory <= 0 {
5454+ maxHistory = 100 // Default to keeping 100 events
5555+ }
5656+5757+ return &EventBroadcaster{
5858+ subscribers: make(map[*Subscriber]bool),
5959+ eventSeq: 0,
6060+ eventHistory: make([]HistoricalEvent, 0, maxHistory),
6161+ maxHistory: maxHistory,
6262+ holdDID: holdDID,
6363+ }
6464+}
6565+6666+// Subscribe adds a new WebSocket subscriber
6767+func (b *EventBroadcaster) Subscribe(conn *websocket.Conn, cursor int64) *Subscriber {
6868+ sub := &Subscriber{
6969+ conn: conn,
7070+ send: make(chan *RepoCommitEvent, 10), // Buffer 10 events
7171+ cursor: cursor,
7272+ }
7373+7474+ b.mu.Lock()
7575+ b.subscribers[sub] = true
7676+ currentSeq := b.eventSeq
7777+ b.mu.Unlock()
7878+7979+ // Send historical events if cursor is provided and < current seq
8080+ if cursor > 0 && cursor < currentSeq {
8181+ go b.backfillSubscriber(sub, cursor)
8282+ }
8383+8484+ // Start goroutine to handle sending events to this subscriber
8585+ go b.handleSubscriber(sub)
8686+8787+ return sub
8888+}
8989+9090+// Unsubscribe removes a WebSocket subscriber
9191+func (b *EventBroadcaster) Unsubscribe(sub *Subscriber) {
9292+ b.mu.Lock()
9393+ defer b.mu.Unlock()
9494+9595+ if _, ok := b.subscribers[sub]; ok {
9696+ delete(b.subscribers, sub)
9797+ close(sub.send)
9898+ }
9999+}
100100+101101+// Broadcast sends an event to all subscribers
102102+func (b *EventBroadcaster) Broadcast(ctx context.Context, event *RepoEvent) {
103103+ b.mu.Lock()
104104+ defer b.mu.Unlock()
105105+106106+ // Increment sequence
107107+ b.eventSeq++
108108+ seq := b.eventSeq
109109+110110+ // Convert RepoEvent to RepoCommitEvent
111111+ commitEvent := b.convertToCommitEvent(event, seq)
112112+113113+ // Store in history for backfill
114114+ b.addToHistory(seq, commitEvent)
115115+116116+ // Broadcast to all subscribers
117117+ for sub := range b.subscribers {
118118+ select {
119119+ case sub.send <- commitEvent:
120120+ // Sent successfully
121121+ default:
122122+ // Subscriber's buffer is full, skip (they'll get disconnected for being too slow)
123123+ log.Printf("Warning: subscriber buffer full, skipping event seq=%d", seq)
124124+ }
125125+ }
126126+}
127127+128128+// convertToCommitEvent converts a RepoEvent to a RepoCommitEvent
129129+func (b *EventBroadcaster) convertToCommitEvent(event *RepoEvent, seq int64) *RepoCommitEvent {
130130+ // Convert RepoOps to atproto.SyncSubscribeRepos_RepoOp
131131+ ops := make([]*atproto.SyncSubscribeRepos_RepoOp, len(event.Ops))
132132+ for i, op := range event.Ops {
133133+ action := string(op.Kind) // "create", "update", "delete"
134134+ path := op.Collection + "/" + op.Rkey
135135+136136+ // Convert CID to LexLink if present
137137+ var cidLink *lexutil.LexLink
138138+ if op.RecCid != nil {
139139+ link := lexutil.LexLink(*op.RecCid)
140140+ cidLink = &link
141141+ }
142142+143143+ ops[i] = &atproto.SyncSubscribeRepos_RepoOp{
144144+ Action: action,
145145+ Path: path,
146146+ Cid: cidLink,
147147+ }
148148+ }
149149+150150+ // Event.NewRoot is a cid.Cid, convert to string
151151+ commitCID := event.NewRoot.String()
152152+153153+ return &RepoCommitEvent{
154154+ Seq: seq,
155155+ Repo: b.holdDID, // Set to hold's DID
156156+ Commit: commitCID,
157157+ Rev: event.Rev,
158158+ Since: event.Since,
159159+ Blocks: event.RepoSlice, // CAR slice bytes
160160+ Ops: ops,
161161+ Time: time.Now().Format(time.RFC3339),
162162+ Type: "#commit",
163163+ }
164164+}
165165+166166+// addToHistory adds an event to the history ring buffer
167167+func (b *EventBroadcaster) addToHistory(seq int64, event *RepoCommitEvent) {
168168+ he := HistoricalEvent{
169169+ Seq: seq,
170170+ Event: event,
171171+ }
172172+173173+ // Simple ring buffer: keep last N events
174174+ if len(b.eventHistory) >= b.maxHistory {
175175+ // Remove oldest event
176176+ b.eventHistory = b.eventHistory[1:]
177177+ }
178178+ b.eventHistory = append(b.eventHistory, he)
179179+}
180180+181181+// backfillSubscriber sends historical events to a subscriber
182182+func (b *EventBroadcaster) backfillSubscriber(sub *Subscriber, cursor int64) {
183183+ b.mu.RLock()
184184+ defer b.mu.RUnlock()
185185+186186+ for _, he := range b.eventHistory {
187187+ if he.Seq > cursor {
188188+ select {
189189+ case sub.send <- he.Event:
190190+ // Sent
191191+ case <-time.After(5 * time.Second):
192192+ // Timeout, subscriber too slow
193193+ log.Printf("Backfill timeout for subscriber at seq=%d", he.Seq)
194194+ return
195195+ }
196196+ }
197197+ }
198198+}
199199+200200+// handleSubscriber handles sending events to a subscriber over WebSocket
201201+func (b *EventBroadcaster) handleSubscriber(sub *Subscriber) {
202202+ defer func() {
203203+ b.Unsubscribe(sub)
204204+ sub.conn.Close()
205205+ }()
206206+207207+ for event := range sub.send {
208208+ // Encode as CBOR
209209+ cborBytes, err := encodeCBOR(event)
210210+ if err != nil {
211211+ log.Printf("Failed to encode event as CBOR: %v", err)
212212+ continue
213213+ }
214214+215215+ // Write CBOR message to WebSocket
216216+ err = sub.conn.WriteMessage(websocket.BinaryMessage, cborBytes)
217217+ if err != nil {
218218+ log.Printf("Failed to write to websocket: %v", err)
219219+ return
220220+ }
221221+222222+ // Update cursor
223223+ sub.cursor = event.Seq
224224+ }
225225+}
226226+227227+// encodeCBOR encodes an event as CBOR
228228+func encodeCBOR(event *RepoCommitEvent) ([]byte, error) {
229229+ // For now, use JSON encoding wrapped in CBOR envelope
230230+ // In production, you'd use proper CBOR encoding
231231+ // The atproto spec requires DAG-CBOR with specific header
232232+233233+ // Simple approach: encode as JSON for MVP
234234+ // Real implementation needs proper CBOR-gen types
235235+ return json.Marshal(event)
236236+}
237237+238238+// SetRepoEventHandler creates a callback to be registered with RepoManager
239239+func (b *EventBroadcaster) SetRepoEventHandler() func(context.Context, *RepoEvent) {
240240+ return func(ctx context.Context, event *RepoEvent) {
241241+ // Broadcast the event to all subscribers
242242+ // The holdDID is already set in the broadcaster
243243+ b.Broadcast(ctx, event)
244244+ }
245245+}
246246+247247+// GetCurrentSeq returns the current event sequence number
248248+func (b *EventBroadcaster) GetCurrentSeq() int64 {
249249+ b.mu.RLock()
250250+ defer b.mu.RUnlock()
251251+ return b.eventSeq
252252+}
+5
pkg/hold/pds/server.go
···102102 return p.signingKey
103103}
104104105105+// RepomgrRef returns a reference to the RepoManager for event handler setup
106106+func (p *HoldPDS) RepomgrRef() *RepoManager {
107107+ return p.repomgr
108108+}
109109+105110// Bootstrap initializes the hold with the captain record and owner as first crew member
106111func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) error {
107112 if ownerDID == "" {
+109-7
pkg/hold/pds/xrpc.go
···55 "encoding/json"
66 "fmt"
77 "net/http"
88+ "strconv"
89 "strings"
9101011 "atcr.io/pkg/atproto"
1112 lexutil "github.com/bluesky-social/indigo/lex/util"
1213 "github.com/bluesky-social/indigo/repo"
1414+ "github.com/gorilla/websocket"
1315 "github.com/ipfs/go-cid"
1416 "github.com/ipld/go-car"
1517 carutil "github.com/ipld/go-car/util"
···19212022// XRPCHandler handles XRPC requests for the embedded PDS
2123type XRPCHandler struct {
2222- pds *HoldPDS
2323- publicURL string
2424- blobStore BlobStore
2424+ pds *HoldPDS
2525+ publicURL string
2626+ blobStore BlobStore
2727+ broadcaster *EventBroadcaster
2528}
26292730// BlobStore interface wraps the existing hold service storage operations
···3336}
34373538// NewXRPCHandler creates a new XRPC handler
3636-func NewXRPCHandler(pds *HoldPDS, publicURL string, blobStore BlobStore) *XRPCHandler {
3939+func NewXRPCHandler(pds *HoldPDS, publicURL string, blobStore BlobStore, broadcaster *EventBroadcaster) *XRPCHandler {
3740 return &XRPCHandler{
3838- pds: pds,
3939- publicURL: publicURL,
4040- blobStore: blobStore,
4141+ pds: pds,
4242+ publicURL: publicURL,
4343+ blobStore: blobStore,
4444+ broadcaster: broadcaster,
4145 }
4246}
4347···7276 // Sync endpoints
7377 mux.HandleFunc("/xrpc/com.atproto.sync.listRepos", corsMiddleware(h.HandleListRepos))
7478 mux.HandleFunc("/xrpc/com.atproto.sync.getRecord", corsMiddleware(h.HandleSyncGetRecord))
7979+ mux.HandleFunc("/xrpc/com.atproto.sync.getRepo", corsMiddleware(h.HandleGetRepo))
8080+ mux.HandleFunc("/xrpc/com.atproto.sync.subscribeRepos", corsMiddleware(h.HandleSubscribeRepos))
75817682 // Blob endpoints (wrap existing presigned URL logic)
7783 mux.HandleFunc("/xrpc/com.atproto.repo.uploadBlob", corsMiddleware(h.HandleUploadBlob))
···438444439445 // Write the CAR data to the response
440446 w.Write(buf.Bytes())
447447+}
448448+449449+// HandleGetRepo returns the full repository as a CAR file
450450+// This is the critical endpoint for relay crawling and Bluesky discovery
451451+func (h *XRPCHandler) HandleGetRepo(w http.ResponseWriter, r *http.Request) {
452452+ if r.Method != http.MethodGet {
453453+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
454454+ return
455455+ }
456456+457457+ // Get required 'did' parameter
458458+ did := r.URL.Query().Get("did")
459459+ if did == "" {
460460+ http.Error(w, "missing required parameter: did", http.StatusBadRequest)
461461+ return
462462+ }
463463+464464+ // Validate DID matches this PDS
465465+ if did != h.pds.DID() {
466466+ http.Error(w, "repo not found", http.StatusNotFound)
467467+ return
468468+ }
469469+470470+ // Get optional 'since' parameter for diff export
471471+ since := r.URL.Query().Get("since")
472472+473473+ // Set CAR content type
474474+ w.Header().Set("Content-Type", "application/vnd.ipld.car")
475475+476476+ // Stream the repository CAR file directly to the response
477477+ // ReadRepo handles full export or diff based on 'since' parameter
478478+ err := h.pds.repomgr.ReadRepo(r.Context(), h.pds.uid, since, w)
479479+ if err != nil {
480480+ // Error already written to response by ReadRepo streaming
481481+ // Log it but don't try to write another HTTP error
482482+ fmt.Printf("Error streaming repo CAR: %v\n", err)
483483+ return
484484+ }
485485+}
486486+487487+// WebSocket upgrader
488488+var upgrader = websocket.Upgrader{
489489+ CheckOrigin: func(r *http.Request) bool {
490490+ // Allow all origins for MVP (ATProto firehose is public)
491491+ return true
492492+ },
493493+}
494494+495495+// HandleSubscribeRepos handles WebSocket connections for the firehose
496496+// This is the real-time event stream for repo changes
497497+func (h *XRPCHandler) HandleSubscribeRepos(w http.ResponseWriter, r *http.Request) {
498498+ if r.Method != http.MethodGet {
499499+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
500500+ return
501501+ }
502502+503503+ // Check if broadcaster is configured
504504+ if h.broadcaster == nil {
505505+ http.Error(w, "firehose not enabled", http.StatusNotImplemented)
506506+ return
507507+ }
508508+509509+ // Get optional cursor parameter for backfill
510510+ var cursor int64 = 0
511511+ if cursorStr := r.URL.Query().Get("cursor"); cursorStr != "" {
512512+ var err error
513513+ cursor, err = strconv.ParseInt(cursorStr, 10, 64)
514514+ if err != nil {
515515+ http.Error(w, "invalid cursor parameter", http.StatusBadRequest)
516516+ return
517517+ }
518518+ }
519519+520520+ // Upgrade to WebSocket
521521+ conn, err := upgrader.Upgrade(w, r, nil)
522522+ if err != nil {
523523+ fmt.Printf("WebSocket upgrade failed: %v\n", err)
524524+ return
525525+ }
526526+527527+ // Subscribe to events
528528+ sub := h.broadcaster.Subscribe(conn, cursor)
529529+530530+ // The broadcaster's handleSubscriber goroutine will manage this connection
531531+ // We just need to keep reading to detect client disconnects
532532+ go func() {
533533+ defer h.broadcaster.Unsubscribe(sub)
534534+ for {
535535+ // Read messages from client (mostly just to detect disconnect)
536536+ _, _, err := conn.ReadMessage()
537537+ if err != nil {
538538+ // Client disconnected
539539+ break
540540+ }
541541+ }
542542+ }()
441543}
442544443545// HandleUploadBlob wraps existing presigned upload URL logic