implement the ability to promote a hold as a successor as a way to migrate users to a new storage server

This commit is contained in:
Evan Jarrett
2026-02-12 20:14:19 -06:00
parent 8d39daa09d
commit 92c31835e2
33 changed files with 1068 additions and 55 deletions
+2
View File
@@ -19,8 +19,10 @@ deploy/upcloud/state.json
# Generated assets (run go generate to rebuild)
pkg/appview/licenses/spdx-licenses.json
pkg/appview/public/css/style.css
pkg/appview/public/js/htmx.min.js
pkg/appview/public/js/lucide.min.js
pkg/hold/admin/public/css/style.css
# IDE
.zed/
+573
View File
@@ -0,0 +1,573 @@
// record-query queries the ATProto relay to find all users with records in a given
// collection, fetches the records from each user's PDS, and optionally filters them.
//
// Usage:
//
// go run ./cmd/record-query --collection io.atcr.sailor.profile --filter "defaultHold!=prefix:did:web"
// go run ./cmd/record-query --collection io.atcr.manifest
// go run ./cmd/record-query --collection io.atcr.sailor.profile --limit 5
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
)
// ListReposByCollectionResponse is the response from com.atproto.sync.listReposByCollection
type ListReposByCollectionResponse struct {
Repos []RepoRef `json:"repos"`
Cursor string `json:"cursor,omitempty"`
}
// RepoRef is a single repo reference
type RepoRef struct {
DID string `json:"did"`
}
// ListRecordsResponse is the response from com.atproto.repo.listRecords
type ListRecordsResponse struct {
Records []Record `json:"records"`
Cursor string `json:"cursor,omitempty"`
}
// Record is a single ATProto record
type Record struct {
URI string `json:"uri"`
CID string `json:"cid"`
Value json.RawMessage `json:"value"`
}
// MatchResult is a record that passed the filter
type MatchResult struct {
DID string
Handle string
URI string
Fields map[string]any
}
// Filter defines a simple field filter
type Filter struct {
Field string
Operator string // "=", "!="
Mode string // "exact", "prefix", "empty"
Value string
}
var client = &http.Client{Timeout: 30 * time.Second}
func main() {
relay := flag.String("relay", "https://relay1.us-east.bsky.network", "Relay endpoint")
collection := flag.String("collection", "io.atcr.sailor.profile", "ATProto collection to query")
filterStr := flag.String("filter", "", "Filter expression: field=value, field!=value, field=prefix:xxx, field!=prefix:xxx, field=empty, field!=empty")
resolve := flag.Bool("resolve", true, "Resolve DIDs to handles")
limit := flag.Int("limit", 0, "Max repos to process (0 = unlimited)")
flag.Parse()
// Parse filter
var filter *Filter
if *filterStr != "" {
var err error
filter, err = parseFilter(*filterStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid filter: %v\n", err)
os.Exit(1)
}
fmt.Printf("Filter: %s %s %s:%s\n", filter.Field, filter.Operator, filter.Mode, filter.Value)
}
fmt.Printf("Relay: %s\n", *relay)
fmt.Printf("Collection: %s\n", *collection)
if *limit > 0 {
fmt.Printf("Limit: %d repos\n", *limit)
}
fmt.Println()
// Step 1: Enumerate all DIDs with records in this collection
fmt.Println("Enumerating repos from relay...")
dids, err := listAllRepos(*relay, *collection, *limit)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to list repos: %v\n", err)
os.Exit(1)
}
fmt.Printf("Found %d repos with %s records\n\n", len(dids), *collection)
// Step 2: For each DID, fetch records and apply filter
fmt.Println("Fetching records from each user's PDS...")
var results []MatchResult
errorsByCategory := make(map[string][]string) // category -> list of DIDs
for i, did := range dids {
totalErrors := 0
for _, v := range errorsByCategory {
totalErrors += len(v)
}
if (i+1)%10 == 0 || i == len(dids)-1 {
fmt.Printf(" Progress: %d/%d repos (matches: %d, errors: %d)\r", i+1, len(dids), len(results), totalErrors)
}
matches, err := fetchAndFilter(did, *collection, filter)
if err != nil {
cat := categorizeError(err)
errorsByCategory[cat] = append(errorsByCategory[cat], did)
continue
}
results = append(results, matches...)
}
totalErrors := 0
for _, v := range errorsByCategory {
totalErrors += len(v)
}
fmt.Printf(" Progress: %d/%d repos (matches: %d, errors: %d)\n", len(dids), len(dids), len(results), totalErrors)
if len(errorsByCategory) > 0 {
fmt.Println(" Error breakdown:")
var cats []string
for k := range errorsByCategory {
cats = append(cats, k)
}
sort.Strings(cats)
for _, cat := range cats {
dids := errorsByCategory[cat]
fmt.Printf(" %s (%d):\n", cat, len(dids))
for _, did := range dids {
fmt.Printf(" - %s\n", did)
}
}
}
fmt.Println()
// Step 3: Resolve DIDs to handles
if *resolve && len(results) > 0 {
fmt.Println("Resolving DIDs to handles...")
handleCache := make(map[string]string)
for i := range results {
did := results[i].DID
if h, ok := handleCache[did]; ok {
results[i].Handle = h
continue
}
handle, err := resolveDIDToHandle(did)
if err != nil {
handle = did
}
handleCache[did] = handle
results[i].Handle = handle
}
fmt.Println()
}
// Step 4: Print results
if len(results) == 0 {
fmt.Println("No matching records found.")
return
}
// Sort by handle/DID for consistent output
sort.Slice(results, func(i, j int) bool {
return results[i].Handle < results[j].Handle
})
fmt.Println("========================================")
fmt.Printf("RESULTS (%d matches)\n", len(results))
fmt.Println("========================================")
for i, r := range results {
identity := r.Handle
if identity == "" {
identity = r.DID
}
fmt.Printf("\n%3d. %s\n", i+1, identity)
if r.Handle != "" && r.Handle != r.DID {
fmt.Printf(" DID: %s\n", r.DID)
}
fmt.Printf(" URI: %s\n", r.URI)
// Print interesting fields (skip $type, createdAt, updatedAt)
for k, v := range r.Fields {
if k == "$type" || k == "createdAt" || k == "updatedAt" {
continue
}
fmt.Printf(" %s: %v\n", k, v)
}
}
// CSV output
fmt.Println("\n========================================")
fmt.Println("CSV FORMAT")
fmt.Println("========================================")
// Collect all field names for CSV header
fieldSet := make(map[string]bool)
for _, r := range results {
for k := range r.Fields {
if k == "$type" || k == "createdAt" || k == "updatedAt" {
continue
}
fieldSet[k] = true
}
}
var fieldNames []string
for k := range fieldSet {
fieldNames = append(fieldNames, k)
}
sort.Strings(fieldNames)
// Header
fmt.Printf("handle,did,uri")
for _, f := range fieldNames {
fmt.Printf(",%s", f)
}
fmt.Println()
// Rows
for _, r := range results {
identity := r.Handle
if identity == "" {
identity = r.DID
}
fmt.Printf("%s,%s,%s", identity, r.DID, r.URI)
for _, f := range fieldNames {
val := ""
if v, ok := r.Fields[f]; ok {
val = fmt.Sprintf("%v", v)
}
// Escape commas in values
if strings.Contains(val, ",") {
val = "\"" + val + "\""
}
fmt.Printf(",%s", val)
}
fmt.Println()
}
}
// parseFilter parses a filter string like "field!=prefix:did:web"
func parseFilter(s string) (*Filter, error) {
f := &Filter{}
// Check for != first (before =)
if idx := strings.Index(s, "!="); idx > 0 {
f.Field = s[:idx]
f.Operator = "!="
s = s[idx+2:]
} else if idx := strings.Index(s, "="); idx > 0 {
f.Field = s[:idx]
f.Operator = "="
s = s[idx+1:]
} else {
return nil, fmt.Errorf("expected field=value or field!=value, got %q", s)
}
// Check for mode prefix
if s == "empty" {
f.Mode = "empty"
f.Value = ""
} else if strings.HasPrefix(s, "prefix:") {
f.Mode = "prefix"
f.Value = strings.TrimPrefix(s, "prefix:")
} else {
f.Mode = "exact"
f.Value = s
}
return f, nil
}
// matchFilter checks if a record's fields match the filter
func matchFilter(fields map[string]any, filter *Filter) bool {
if filter == nil {
return true
}
val := ""
if v, ok := fields[filter.Field]; ok {
val = fmt.Sprintf("%v", v)
}
switch filter.Mode {
case "empty":
isEmpty := val == "" || val == "<nil>"
if filter.Operator == "=" {
return isEmpty
}
return !isEmpty
case "prefix":
hasPrefix := strings.HasPrefix(val, filter.Value)
if filter.Operator == "=" {
return hasPrefix
}
return !hasPrefix && val != "" && val != "<nil>"
case "exact":
if filter.Operator == "=" {
return val == filter.Value
}
return val != filter.Value
}
return true
}
// categorizeError classifies an error into a human-readable category
func categorizeError(err error) string {
s := err.Error()
// HTTP status codes
for _, code := range []string{"400", "401", "403", "404", "410", "429", "500", "502", "503"} {
if strings.Contains(s, "status "+code) {
switch code {
case "400":
if strings.Contains(s, "RepoDeactivated") || strings.Contains(s, "deactivated") {
return "deactivated (400)"
}
if strings.Contains(s, "RepoTakendown") || strings.Contains(s, "takendown") {
return "takendown (400)"
}
if strings.Contains(s, "RepoNotFound") || strings.Contains(s, "Could not find repo") {
return "repo not found (400)"
}
return "bad request (400)"
case "401":
return "unauthorized (401)"
case "404":
return "not found (404)"
case "410":
return "gone/deleted (410)"
case "429":
return "rate limited (429)"
case "502":
return "bad gateway (502)"
case "503":
return "unavailable (503)"
default:
return fmt.Sprintf("HTTP %s", code)
}
}
}
// Connection errors
if strings.Contains(s, "connection refused") {
return "connection refused"
}
if strings.Contains(s, "no such host") {
return "DNS failure"
}
if strings.Contains(s, "timeout") || strings.Contains(s, "deadline exceeded") {
return "timeout"
}
if strings.Contains(s, "TLS") || strings.Contains(s, "certificate") {
return "TLS error"
}
if strings.Contains(s, "EOF") {
return "connection reset"
}
// PLC/DID errors
if strings.Contains(s, "no PDS found") {
return "no PDS in DID doc"
}
if strings.Contains(s, "unsupported DID method") {
return "unsupported DID method"
}
return "other: " + s
}
// listAllRepos paginates through the relay to get all DIDs with records in a collection
func listAllRepos(relayURL, collection string, limit int) ([]string, error) {
var dids []string
cursor := ""
for {
u := fmt.Sprintf("%s/xrpc/com.atproto.sync.listReposByCollection", relayURL)
params := url.Values{}
params.Set("collection", collection)
params.Set("limit", "1000")
if cursor != "" {
params.Set("cursor", cursor)
}
resp, err := client.Get(u + "?" + params.Encode())
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
var result ListReposByCollectionResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("decode failed: %w", err)
}
resp.Body.Close()
for _, repo := range result.Repos {
dids = append(dids, repo.DID)
}
fmt.Printf(" Fetched %d repos so far...\r", len(dids))
if limit > 0 && len(dids) >= limit {
dids = dids[:limit]
break
}
if result.Cursor == "" {
break
}
cursor = result.Cursor
}
fmt.Println()
return dids, nil
}
// fetchAndFilter fetches records for a DID and returns those matching the filter
func fetchAndFilter(did, collection string, filter *Filter) ([]MatchResult, error) {
// Resolve DID to PDS
pdsEndpoint, err := resolveDIDToPDS(did)
if err != nil {
return nil, fmt.Errorf("resolve PDS: %w", err)
}
var results []MatchResult
cursor := ""
for {
u := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords", pdsEndpoint)
params := url.Values{}
params.Set("repo", did)
params.Set("collection", collection)
params.Set("limit", "100")
if cursor != "" {
params.Set("cursor", cursor)
}
resp, err := client.Get(u + "?" + params.Encode())
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var listResp ListRecordsResponse
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("decode failed: %w", err)
}
resp.Body.Close()
for _, rec := range listResp.Records {
var fields map[string]any
if err := json.Unmarshal(rec.Value, &fields); err != nil {
continue
}
if matchFilter(fields, filter) {
results = append(results, MatchResult{
DID: did,
URI: rec.URI,
Fields: fields,
})
}
}
if listResp.Cursor == "" || len(listResp.Records) < 100 {
break
}
cursor = listResp.Cursor
}
return results, nil
}
// resolveDIDToHandle resolves a DID to a handle using the PLC directory or did:web
func resolveDIDToHandle(did string) (string, error) {
if strings.HasPrefix(did, "did:web:") {
return strings.TrimPrefix(did, "did:web:"), nil
}
if strings.HasPrefix(did, "did:plc:") {
resp, err := client.Get("https://plc.directory/" + did)
if err != nil {
return "", fmt.Errorf("PLC query failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("PLC returned status %d", resp.StatusCode)
}
var plcDoc struct {
AlsoKnownAs []string `json:"alsoKnownAs"`
}
if err := json.NewDecoder(resp.Body).Decode(&plcDoc); err != nil {
return "", fmt.Errorf("failed to parse PLC response: %w", err)
}
for _, aka := range plcDoc.AlsoKnownAs {
if strings.HasPrefix(aka, "at://") {
return strings.TrimPrefix(aka, "at://"), nil
}
}
return did, nil
}
return did, nil
}
// resolveDIDToPDS resolves a DID to its PDS endpoint
func resolveDIDToPDS(did string) (string, error) {
if strings.HasPrefix(did, "did:web:") {
domain := strings.TrimPrefix(did, "did:web:")
return "https://" + domain, nil
}
if strings.HasPrefix(did, "did:plc:") {
resp, err := client.Get("https://plc.directory/" + did)
if err != nil {
return "", fmt.Errorf("PLC query failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("PLC returned status %d", resp.StatusCode)
}
var plcDoc struct {
Service []struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
} `json:"service"`
}
if err := json.NewDecoder(resp.Body).Decode(&plcDoc); err != nil {
return "", fmt.Errorf("failed to parse PLC response: %w", err)
}
for _, svc := range plcDoc.Service {
if svc.Type == "AtprotoPersonalDataServer" {
return svc.ServiceEndpoint, nil
}
}
return "", fmt.Errorf("no PDS found in DID document")
}
return "", fmt.Errorf("unsupported DID method: %s", did)
}
+5 -1
View File
@@ -794,7 +794,11 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request)
allowAllCrew := r.FormValue("allow_all_crew") == "on"
enablePosts := r.FormValue("enable_bluesky_posts") == "on"
_, err := ui.pds.UpdateCaptainRecord(ctx, public, allowAllCrew, enablePosts)
_, captain, _ := ui.pds.GetCaptainRecord(ctx)
captain.Public = public
captain.AllowAllCrew = allowAllCrew
captain.EnableBlueskyPosts = enablePosts
_, err := ui.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
ui.setFlash(w, "error", "Failed to update settings: "+err.Error())
http.Redirect(w, r, "/admin/settings", http.StatusFound)
+18 -7
View File
@@ -25,7 +25,8 @@ type HoldCaptainRecord struct {
AllowAllCrew bool `json:"allowAllCrew"`
DeployedAt string `json:"deployedAt"`
Region string `json:"region"`
UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
Successor string `json:"successor"` // DID of successor hold (migration redirect)
UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
}
// GetCaptainRecord retrieves a captain record from the cache
@@ -33,13 +34,13 @@ type HoldCaptainRecord struct {
func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) {
query := `
SELECT hold_did, owner_did, public, allow_all_crew,
deployed_at, region, updated_at
deployed_at, region, successor, updated_at
FROM hold_captain_records
WHERE hold_did = ?
`
var record HoldCaptainRecord
var deployedAt, region sql.NullString
var deployedAt, region, successor sql.NullString
err := db.QueryRow(query, holdDID).Scan(
&record.HoldDID,
@@ -48,6 +49,7 @@ func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) {
&record.AllowAllCrew,
&deployedAt,
&region,
&successor,
&record.UpdatedAt,
)
@@ -66,6 +68,9 @@ func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) {
if region.Valid {
record.Region = region.String
}
if successor.Valid {
record.Successor = successor.String
}
return &record, nil
}
@@ -75,14 +80,15 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error {
query := `
INSERT INTO hold_captain_records (
hold_did, owner_did, public, allow_all_crew,
deployed_at, region, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
deployed_at, region, successor, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(hold_did) DO UPDATE SET
owner_did = excluded.owner_did,
public = excluded.public,
allow_all_crew = excluded.allow_all_crew,
deployed_at = excluded.deployed_at,
region = excluded.region,
successor = excluded.successor,
updated_at = excluded.updated_at
`
@@ -93,6 +99,7 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error {
record.AllowAllCrew,
nullString(record.DeployedAt),
nullString(record.Region),
nullString(record.Successor),
record.UpdatedAt,
)
@@ -146,7 +153,7 @@ func nullString(s string) sql.NullString {
func GetCaptainRecordsForOwner(db DBTX, ownerDID string) ([]*HoldCaptainRecord, error) {
query := `
SELECT hold_did, owner_did, public, allow_all_crew,
deployed_at, region, updated_at
deployed_at, region, successor, updated_at
FROM hold_captain_records
WHERE owner_did = ?
ORDER BY updated_at DESC
@@ -161,7 +168,7 @@ func GetCaptainRecordsForOwner(db DBTX, ownerDID string) ([]*HoldCaptainRecord,
var records []*HoldCaptainRecord
for rows.Next() {
var record HoldCaptainRecord
var deployedAt, region sql.NullString
var deployedAt, region, successor sql.NullString
err := rows.Scan(
&record.HoldDID,
@@ -170,6 +177,7 @@ func GetCaptainRecordsForOwner(db DBTX, ownerDID string) ([]*HoldCaptainRecord,
&record.AllowAllCrew,
&deployedAt,
&region,
&successor,
&record.UpdatedAt,
)
if err != nil {
@@ -182,6 +190,9 @@ func GetCaptainRecordsForOwner(db DBTX, ownerDID string) ([]*HoldCaptainRecord,
if region.Valid {
record.Region = region.String
}
if successor.Valid {
record.Successor = successor.String
}
records = append(records, &record)
}
@@ -0,0 +1,3 @@
description: Add successor column to hold_captain_records for hold migration
query: |
ALTER TABLE hold_captain_records ADD COLUMN successor TEXT;
+19
View File
@@ -1598,6 +1598,20 @@ func parseTimestamp(s string) (time.Time, error) {
return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s)
}
// UpdateManifestHoldDID rewrites the hold_endpoint column for all manifests
// belonging to a user that currently point to oldHoldDID, changing them to newHoldDID.
// Returns the number of rows affected.
func UpdateManifestHoldDID(db DBTX, did, oldHoldDID, newHoldDID string) (int64, error) {
result, err := db.Exec(`
UPDATE manifests SET hold_endpoint = ?
WHERE did = ? AND hold_endpoint = ?
`, newHoldDID, did, oldHoldDID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// HoldDIDDB wraps a sql.DB and implements the HoldDIDLookup interface for middleware
// This is a minimal wrapper that only provides hold DID lookups for blob routing
type HoldDIDDB struct {
@@ -1614,6 +1628,11 @@ func (h *HoldDIDDB) GetLatestHoldDIDForRepo(did, repository string) (string, err
return GetLatestHoldDIDForRepo(h.db, did, repository)
}
// UpdateManifestHoldDID rewrites hold_endpoint for all manifests belonging to a user
func (h *HoldDIDDB) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error) {
return UpdateManifestHoldDID(h.db, did, oldHoldDID, newHoldDID)
}
// RepoCardSortOrder specifies how repo cards should be sorted
type RepoCardSortOrder string
+1
View File
@@ -183,6 +183,7 @@ CREATE TABLE IF NOT EXISTS hold_captain_records (
allow_all_crew BOOLEAN NOT NULL,
deployed_at TEXT,
region TEXT,
successor TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
+1
View File
@@ -677,6 +677,7 @@ func (p *Processor) ProcessCaptain(ctx context.Context, holdDID string, recordDa
AllowAllCrew: captainRecord.AllowAllCrew,
DeployedAt: captainRecord.DeployedAt,
Region: captainRecord.Region,
Successor: captainRecord.Successor,
UpdatedAt: time.Now(),
}
+24
View File
@@ -293,6 +293,9 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
// This is a fatal configuration error - registry cannot function without a hold service
return nil, fmt.Errorf("no hold DID configured: ensure default_hold_did is set in middleware config")
}
// Single-hop hold migration: check if this hold has declared a successor
holdDID = nr.resolveSuccessor(ctx, holdDID)
// Auto-reconcile crew membership on first push/pull
// This ensures users can push immediately after docker login without web sign-in
// EnsureCrewMembership is best-effort and logs errors without failing the request
@@ -524,6 +527,27 @@ func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint s
return nr.defaultHoldDID
}
// resolveSuccessor checks if a hold has declared a successor and returns it.
// Single-hop only — does not follow chains. Returns the original holdDID if
// no successor is set or if the captain record can't be fetched.
func (nr *NamespaceResolver) resolveSuccessor(ctx context.Context, holdDID string) string {
if nr.authorizer == nil {
return holdDID
}
captain, err := nr.authorizer.GetCaptainRecord(ctx, holdDID)
if err != nil {
return holdDID
}
if captain != nil && captain.Successor != "" {
slog.Info("Hold successor redirect",
"component", "registry/middleware",
"from", holdDID,
"to", captain.Successor)
return captain.Successor
}
return holdDID
}
// isHoldReachable checks if a hold service is reachable
// Used in test mode to fallback to default hold when user's hold is unavailable
func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string) bool {
File diff suppressed because one or more lines are too long
+7
View File
@@ -382,6 +382,13 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
}
}(client, did)
// Drain manifests from old hold to successor in background
go func(client *atproto.Client, did string) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
storage.MigrateManifestsForSuccessor(ctx, client, s.HoldAuthorizer, db.NewHoldDIDDB(s.Database), did)
}(client, did)
// Run consumer hooks
for _, hook := range s.oauthHooks {
if err := hook(ctx, did, handle, pdsEndpoint, sessionID); err != nil {
+2 -1
View File
@@ -7,9 +7,10 @@ import (
"atcr.io/pkg/auth/oauth"
)
// HoldDIDLookup interface for querying hold DIDs from manifests
// HoldDIDLookup interface for querying and updating hold DIDs in manifests
type HoldDIDLookup interface {
GetLatestHoldDIDForRepo(did, repository string) (string, error)
UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error)
}
// RegistryContext bundles all the context needed for registry operations
+4
View File
@@ -17,6 +17,10 @@ func (m *mockHoldDIDLookup) GetLatestHoldDIDForRepo(did, repository string) (str
return m.holdDID, nil
}
func (m *mockHoldDIDLookup) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error) {
return 0, nil
}
func TestRegistryContext_Fields(t *testing.T) {
// Create a sample RegistryContext
ctx := &RegistryContext{
+138
View File
@@ -0,0 +1,138 @@
package storage
import (
"context"
"encoding/json"
"log/slog"
"strings"
"sync"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
)
// drainLocks prevents concurrent drain operations per DID.
// If a drain is already running for a user (from login or push), skip.
var drainLocks sync.Map
// MigrateManifestsForSuccessor rewrites manifest records and profile
// when a user's defaultHold has a successor. Best-effort, runs in background.
//
// Steps:
// 1. Get user's sailor profile — check if defaultHold has a successor
// 2. Update profile.DefaultHold from oldHold → newHold
// 3. Walk all io.atcr.manifest records, rewrite holdDid from oldHold → newHold
// 4. Update appview's local manifests table to match
func MigrateManifestsForSuccessor(
ctx context.Context,
client *atproto.Client,
authorizer auth.HoldAuthorizer,
db HoldDIDLookup,
did string,
) {
// Lock per DID — skip if already running
if _, loaded := drainLocks.LoadOrStore(did, true); loaded {
return
}
defer drainLocks.Delete(did)
// 1. Get user's profile
profile, err := GetProfile(ctx, client)
if err != nil {
slog.Debug("Drain: failed to get profile", "component", "storage/drain", "did", did, "error", err)
return
}
if profile == nil || profile.DefaultHold == "" {
return
}
// 2. Check if their defaultHold has a successor
oldHold := profile.DefaultHold
captain, err := authorizer.GetCaptainRecord(ctx, oldHold)
if err != nil {
slog.Debug("Drain: failed to get captain record", "component", "storage/drain", "did", did, "hold", oldHold, "error", err)
return
}
if captain == nil || captain.Successor == "" {
return // No successor — nothing to drain
}
newHold := captain.Successor
slog.Info("Starting hold drain", "component", "storage/drain", "did", did, "from", oldHold, "to", newHold)
// 3. Update profile.DefaultHold
profile.DefaultHold = newHold
profile.UpdatedAt = time.Now()
if err := UpdateProfile(ctx, client, profile); err != nil {
slog.Warn("Drain: failed to update profile", "component", "storage/drain", "did", did, "error", err)
// Continue — manifest rewrite is still valuable even if profile update fails
} else {
slog.Info("Drain: updated profile defaultHold", "component", "storage/drain", "did", did, "newHold", newHold)
}
// 4. Walk manifest records, rewrite holdDid
cursor := ""
rewritten := 0
for {
records, nextCursor, err := client.ListRecordsWithCursor(ctx, atproto.ManifestCollection, 100, cursor)
if err != nil {
slog.Warn("Drain: failed to list manifest records", "component", "storage/drain", "did", did, "error", err)
break
}
for _, rec := range records {
var manifest atproto.ManifestRecord
if err := json.Unmarshal(rec.Value, &manifest); err != nil {
slog.Debug("Drain: failed to unmarshal manifest", "component", "storage/drain", "uri", rec.URI, "error", err)
continue
}
// Check if this manifest points to the old hold (via DID or legacy endpoint)
needsRewrite := false
if manifest.HoldDID == oldHold {
needsRewrite = true
} else if manifest.HoldEndpoint != "" && atproto.ResolveHoldDIDFromURL(manifest.HoldEndpoint) == oldHold {
needsRewrite = true
}
if !needsRewrite {
continue
}
// Rewrite to new hold
manifest.HoldDID = newHold
manifest.HoldEndpoint = "" // Clear legacy field
// Extract rkey from AT URI (at://did/collection/rkey)
uriParts := strings.Split(rec.URI, "/")
if len(uriParts) < 2 {
continue
}
rkey := uriParts[len(uriParts)-1]
if _, err := client.PutRecord(ctx, atproto.ManifestCollection, rkey, &manifest); err != nil {
slog.Warn("Drain: failed to rewrite manifest", "component", "storage/drain", "uri", rec.URI, "error", err)
continue
}
rewritten++
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
// 5. Update appview's local manifests table
if db != nil {
dbUpdated, err := db.UpdateManifestHoldDID(did, oldHold, newHold)
if err != nil {
slog.Warn("Drain: failed to update local DB", "component", "storage/drain", "did", did, "error", err)
} else if dbUpdated > 0 {
slog.Info("Drain: updated local DB manifests", "component", "storage/drain", "did", did, "rows", dbUpdated)
}
}
slog.Info("Hold drain complete", "component", "storage/drain", "did", did, "from", oldHold, "to", newHold, "rewritten", rewritten)
}
+14
View File
@@ -254,6 +254,20 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
s.ensureRepoPage(ctx, manifestRecord)
}()
// Drain old manifests from predecessor hold in background (if successor exists)
if s.ctx.Authorizer != nil && s.ctx.Database != nil {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("Panic in MigrateManifestsForSuccessor", "panic", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
MigrateManifestsForSuccessor(ctx, s.ctx.ATProtoClient, s.ctx.Authorizer, s.ctx.Database, s.ctx.DID)
}()
}
return dgst, nil
}
+12
View File
@@ -70,6 +70,18 @@ func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
holdDID = dbHoldDID
holdSource = "database"
slog.Debug("Using hold from database manifest (pull)", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", dbHoldDID)
// Single-hop hold migration: check if the historical hold has a successor
if r.Ctx.Authorizer != nil {
if captain, captErr := r.Ctx.Authorizer.GetCaptainRecord(ctx, dbHoldDID); captErr == nil && captain != nil && captain.Successor != "" {
slog.Info("Hold successor redirect (pull)",
"component", "storage/blobs",
"from", dbHoldDID,
"to", captain.Successor)
holdDID = captain.Successor
holdSource = "successor"
}
}
} else if err != nil {
// Log error but don't fail - fall back to discovery-based DID
slog.Warn("Failed to query database for hold DID", "component", "storage/blobs", "error", err)
@@ -33,6 +33,10 @@ func (m *mockDatabase) GetLatestHoldDIDForRepo(did, repository string) (string,
return m.holdDID, nil
}
func (m *mockDatabase) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error) {
return 0, nil
}
func TestNewRoutingRepository(t *testing.T) {
ctx := &RegistryContext{
DID: "did:plc:test123",
+42 -1
View File
@@ -377,12 +377,16 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
}
cw := cbg.NewCborWriter(w)
fieldCount := 7
fieldCount := 8
if t.Region == "" {
fieldCount--
}
if t.Successor == "" {
fieldCount--
}
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
return err
}
@@ -475,6 +479,32 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
}
}
// t.Successor (string) (string)
if t.Successor != "" {
if len("successor") > 8192 {
return xerrors.Errorf("Value in field \"successor\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("successor"))); err != nil {
return err
}
if _, err := cw.WriteString(string("successor")); err != nil {
return err
}
if len(t.Successor) > 8192 {
return xerrors.Errorf("Value in field t.Successor was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Successor))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Successor)); err != nil {
return err
}
}
// t.DeployedAt (string) (string)
if len("deployedAt") > 8192 {
return xerrors.Errorf("Value in field \"deployedAt\" was too long")
@@ -624,6 +654,17 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
t.Region = string(sval)
}
// t.Successor (string) (string)
case "successor":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Successor = string(sval)
}
// t.DeployedAt (string) (string)
case "deployedAt":
+6
View File
@@ -107,6 +107,12 @@ const (
// Response: Record data
SyncGetRecord = "/xrpc/com.atproto.sync.getRecord"
// SyncListBlobs lists blob CIDs for an account.
// Method: GET
// Query: did={did}&since={since}&limit={limit}&cursor={cursor}
// Response: {"cids": ["..."], "cursor": "..."}
SyncListBlobs = "/xrpc/com.atproto.sync.listBlobs"
// SyncListRepos lists all repositories on a PDS.
// Method: GET
// Response: {"repos": [{...}]}
+3
View File
@@ -27,6 +27,7 @@ func TestEndpointsFormat(t *testing.T) {
{"SyncGetBlob", SyncGetBlob, "com.atproto.sync"},
{"SyncGetRepo", SyncGetRepo, "com.atproto.sync"},
{"SyncGetRecord", SyncGetRecord, "com.atproto.sync"},
{"SyncListBlobs", SyncListBlobs, "com.atproto.sync"},
{"SyncListRepos", SyncListRepos, "com.atproto.sync"},
{"SyncListReposByCollection", SyncListReposByCollection, "com.atproto.sync"},
{"SyncSubscribeRepos", SyncSubscribeRepos, "com.atproto.sync"},
@@ -106,6 +107,7 @@ func TestEndpointUniqueness(t *testing.T) {
SyncGetBlob,
SyncGetRepo,
SyncGetRecord,
SyncListBlobs,
SyncListRepos,
SyncListReposByCollection,
SyncSubscribeRepos,
@@ -163,6 +165,7 @@ func TestEndpointNamespaces(t *testing.T) {
SyncGetBlob,
SyncGetRepo,
SyncGetRecord,
SyncListBlobs,
SyncListRepos,
SyncListReposByCollection,
SyncSubscribeRepos,
+7 -6
View File
@@ -693,12 +693,13 @@ func (t *TagRecord) GetManifestDigest() (string, error) {
// Uses CBOR encoding for efficient storage in hold's carstore
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
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
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
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
}
// CrewRecord represents a crew member in the hold
+12 -4
View File
@@ -144,13 +144,13 @@ type captainRecordWithMeta struct {
// getCachedCaptainRecord retrieves a captain record from database cache
func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainRecordWithMeta, error) {
query := `
SELECT owner_did, public, allow_all_crew, deployed_at, region, updated_at
SELECT owner_did, public, allow_all_crew, deployed_at, region, successor, updated_at
FROM hold_captain_records
WHERE hold_did = ?
`
var record atproto.CaptainRecord
var deployedAt, region sql.NullString
var deployedAt, region, successor sql.NullString
var updatedAt time.Time
err := a.db.QueryRow(query, holdDID).Scan(
@@ -159,6 +159,7 @@ func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainR
&record.AllowAllCrew,
&deployedAt,
&region,
&successor,
&updatedAt,
)
@@ -177,6 +178,9 @@ func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainR
if region.Valid {
record.Region = region.String
}
if successor.Valid {
record.Successor = successor.String
}
return &captainRecordWithMeta{
CaptainRecord: &record,
@@ -189,14 +193,15 @@ func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *at
query := `
INSERT INTO hold_captain_records (
hold_did, owner_did, public, allow_all_crew,
deployed_at, region, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
deployed_at, region, successor, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(hold_did) DO UPDATE SET
owner_did = excluded.owner_did,
public = excluded.public,
allow_all_crew = excluded.allow_all_crew,
deployed_at = excluded.deployed_at,
region = excluded.region,
successor = excluded.successor,
updated_at = excluded.updated_at
`
@@ -207,6 +212,7 @@ func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *at
record.AllowAllCrew,
nullString(record.DeployedAt),
nullString(record.Region),
nullString(record.Successor),
time.Now(),
)
@@ -250,6 +256,7 @@ func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, h
AllowAllCrew bool `json:"allowAllCrew"`
DeployedAt string `json:"deployedAt"`
Region string `json:"region,omitempty"`
Successor string `json:"successor,omitempty"`
} `json:"value"`
}
@@ -265,6 +272,7 @@ func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, h
AllowAllCrew: xrpcResp.Value.AllowAllCrew,
DeployedAt: xrpcResp.Value.DeployedAt,
Region: xrpcResp.Value.Region,
Successor: xrpcResp.Value.Successor,
}
return record, nil
+33 -3
View File
@@ -4,7 +4,9 @@ import (
"context"
"log/slog"
"net/http"
"strings"
"atcr.io/pkg/atproto"
"github.com/spf13/viper"
)
@@ -13,6 +15,7 @@ type settingsData struct {
Public bool
AllowAllCrew bool
EnableBlueskyPosts bool
Successor string
OwnerDID string
OwnerHandle string
HoldDID string
@@ -42,6 +45,7 @@ func (ui *AdminUI) getSettingsData(ctx context.Context) (*settingsData, error) {
Public: captain.Public,
AllowAllCrew: captain.AllowAllCrew,
EnableBlueskyPosts: captain.EnableBlueskyPosts,
Successor: captain.Successor,
OwnerDID: captain.Owner,
OwnerHandle: ownerHandle,
HoldDID: ui.pds.DID(),
@@ -80,8 +84,32 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request)
public := r.FormValue("public") == "on"
allowAllCrew := r.FormValue("allow_all_crew") == "on"
enablePosts := r.FormValue("enable_bluesky_posts") == "on"
successor := strings.TrimSpace(r.FormValue("successor"))
_, err := ui.pds.UpdateCaptainRecord(ctx, public, allowAllCrew, enablePosts)
// Validate successor DID format if provided
if successor != "" {
if !atproto.IsDID(successor) || !strings.HasPrefix(successor, "did:web:") {
setFlash(w, r, "error", "Successor must be a valid did:web: DID (e.g., did:web:hold.example.com)")
http.Redirect(w, r, "/admin#settings", http.StatusFound)
return
}
}
// Get existing captain record, modify fields, write back
_, captain, getErr := ui.pds.GetCaptainRecord(ctx)
if getErr != nil {
slog.Error("Failed to get captain record", "error", getErr)
setFlash(w, r, "error", "Failed to read settings: "+getErr.Error())
http.Redirect(w, r, "/admin#settings", http.StatusFound)
return
}
captain.Public = public
captain.AllowAllCrew = allowAllCrew
captain.EnableBlueskyPosts = enablePosts
captain.Successor = successor
_, err := ui.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
slog.Error("Failed to update captain record", "error", err)
setFlash(w, r, "error", "Failed to update settings: "+err.Error())
@@ -94,11 +122,12 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request)
"public", public,
"allowAllCrew", allowAllCrew,
"enableBlueskyPosts", enablePosts,
"successor", successor,
"by", session.DID)
// Write settings back to YAML config file (if one exists)
if ui.config.ConfigPath != "" {
if err := ui.writeConfigSettings(public, allowAllCrew, enablePosts); err != nil {
if err := ui.writeConfigSettings(public, allowAllCrew, enablePosts, successor); err != nil {
slog.Warn("Failed to write settings to config file",
"path", ui.config.ConfigPath, "error", err)
@@ -118,7 +147,7 @@ func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request)
// writeConfigSettings updates the toggleable settings in the YAML config file.
// Uses a fresh Viper instance to avoid baking env var overrides into the file.
func (ui *AdminUI) writeConfigSettings(public, allowAllCrew, enablePosts bool) error {
func (ui *AdminUI) writeConfigSettings(public, allowAllCrew, enablePosts bool, successor string) error {
v := viper.New()
v.SetConfigFile(ui.config.ConfigPath)
@@ -127,6 +156,7 @@ func (ui *AdminUI) writeConfigSettings(public, allowAllCrew, enablePosts bool) e
}
v.Set("server.public", public)
v.Set("server.successor", successor)
v.Set("registration.allow_all_crew", allowAllCrew)
v.Set("registration.enable_bluesky_posts", enablePosts)
File diff suppressed because one or more lines are too long
@@ -40,6 +40,24 @@
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Migration</h2>
<div class="p-4 bg-base-200 rounded-lg">
<label class="flex flex-col gap-2">
<span class="flex flex-col">
<strong>Successor Hold</strong>
<small class="text-base-content/60">DID of the successor hold. When set, the appview redirects all requests to the successor.</small>
</span>
<input type="text" name="successor" class="input input-bordered w-full font-mono text-sm"
placeholder="did:web:hold.example.com"
value="{{.Settings.Successor}}">
</label>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Hold Information</h2>
+4
View File
@@ -111,6 +111,9 @@ type ServerConfig struct {
// Allow unauthenticated blob reads.
Public bool `yaml:"public" comment:"Allow unauthenticated blob reads. If false, readers need crew membership."`
// DID of successor hold for migration.
Successor string `yaml:"successor" comment:"DID of successor hold for migration. Appview redirects all requests to the successor."`
// Use localhost for OAuth redirects during development.
TestMode bool `yaml:"test_mode" comment:"Use localhost for OAuth redirects during development."`
@@ -157,6 +160,7 @@ func setHoldDefaults(v *viper.Viper) {
v.SetDefault("server.addr", ":8080")
v.SetDefault("server.public_url", "")
v.SetDefault("server.public", false)
v.SetDefault("server.successor", "")
v.SetDefault("server.test_mode", false)
v.SetDefault("server.relay_endpoint", "")
v.SetDefault("server.read_timeout", "5m")
+9 -2
View File
@@ -742,13 +742,20 @@ func TestValidateBlobReadAccess_PrivateHold(t *testing.T) {
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, false, false)
// Update captain to be private
_, err := pds.UpdateCaptainRecord(ctx, false, false, false)
_, captain, err := pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record for update: %v", err)
}
captain.Public = false
captain.AllowAllCrew = false
captain.EnableBlueskyPosts = false
_, err = pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
t.Fatalf("Failed to update captain record: %v", err)
}
// Verify captain record has public=false
_, captain, err := pds.GetCaptainRecord(ctx)
_, captain, err = pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record: %v", err)
}
+4 -14
View File
@@ -58,20 +58,10 @@ func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.Capta
return recordCID, captainRecord, nil
}
// UpdateCaptainRecord updates the captain record (e.g., to change public/allowAllCrew/enableBlueskyPosts settings)
func (p *HoldPDS) UpdateCaptainRecord(ctx context.Context, public bool, allowAllCrew bool, enableBlueskyPosts 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
existing.EnableBlueskyPosts = enableBlueskyPosts
recordCID, err := p.repomgr.UpdateRecord(ctx, p.uid, atproto.CaptainCollection, CaptainRkey, existing)
// UpdateCaptainRecord replaces the captain record with the provided record.
// Callers should GetCaptainRecord first, modify fields, then pass the updated record.
func (p *HoldPDS) UpdateCaptainRecord(ctx context.Context, record *atproto.CaptainRecord) (cid.Cid, error) {
recordCID, err := p.repomgr.UpdateRecord(ctx, p.uid, atproto.CaptainCollection, CaptainRkey, record)
if err != nil {
return cid.Undef, fmt.Errorf("failed to update captain record: %w", err)
}
+14 -5
View File
@@ -245,7 +245,10 @@ func TestUpdateCaptainRecord(t *testing.T) {
}
// Update to public=true, allowAllCrew=true, enableBlueskyPosts=true
updatedCID, err := pds.UpdateCaptainRecord(ctx, true, true, true)
captain1.Public = true
captain1.AllowAllCrew = true
captain1.EnableBlueskyPosts = true
updatedCID, err := pds.UpdateCaptainRecord(ctx, captain1)
if err != nil {
t.Fatalf("UpdateCaptainRecord failed: %v", err)
}
@@ -282,7 +285,9 @@ func TestUpdateCaptainRecord(t *testing.T) {
}
// Update again to different values (public=true, allowAllCrew=false, enableBlueskyPosts=false)
_, err = pds.UpdateCaptainRecord(ctx, true, false, false)
captain2.AllowAllCrew = false
captain2.EnableBlueskyPosts = false
_, err = pds.UpdateCaptainRecord(ctx, captain2)
if err != nil {
t.Fatalf("Second UpdateCaptainRecord failed: %v", err)
}
@@ -307,15 +312,19 @@ func TestUpdateCaptainRecord_NotFound(t *testing.T) {
defer pds.Close()
// Try to update captain record before creating one
_, err := pds.UpdateCaptainRecord(ctx, true, true, true)
record := &atproto.CaptainRecord{
Type: atproto.CaptainCollection,
Public: true,
}
_, err := pds.UpdateCaptainRecord(ctx, record)
if err == nil {
t.Fatal("Expected error when updating non-existent captain record")
}
// Verify error message
errMsg := err.Error()
if !strings.Contains(errMsg, "failed to get existing captain record") {
t.Errorf("Expected 'failed to get existing captain record' in error, got: %s", errMsg)
if !strings.Contains(errMsg, "failed to update captain record") {
t.Errorf("Expected 'failed to update captain record' in error, got: %s", errMsg)
}
}
+5 -2
View File
@@ -291,8 +291,11 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
existingCaptain.EnableBlueskyPosts != p.enableBlueskyPosts
if needsUpdate {
// Update captain record to match env vars
_, err = p.UpdateCaptainRecord(ctx, public, allowAllCrew, p.enableBlueskyPosts)
// Update captain record to match env vars (preserves other fields like Successor)
existingCaptain.Public = public
existingCaptain.AllowAllCrew = allowAllCrew
existingCaptain.EnableBlueskyPosts = p.enableBlueskyPosts
_, err = p.UpdateCaptainRecord(ctx, existingCaptain)
if err != nil {
return fmt.Errorf("failed to update captain record: %w", err)
}
+38
View File
@@ -167,6 +167,7 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
r.Get(atproto.RepoListRecords, h.HandleListRecords)
// Sync endpoints
r.Get(atproto.SyncListBlobs, h.HandleListBlobs)
r.Get(atproto.SyncListRepos, h.HandleListRepos)
r.Get(atproto.SyncGetRecord, h.HandleSyncGetRecord)
r.Get(atproto.SyncGetRepo, h.HandleGetRepo)
@@ -1240,6 +1241,43 @@ func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) {
})
}
// HandleListBlobs lists blob CIDs for an account
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-list-blobs
func (h *XRPCHandler) HandleListBlobs(w http.ResponseWriter, r *http.Request) {
did := r.URL.Query().Get("did")
if did == "" {
http.Error(w, "missing required parameter: did", http.StatusBadRequest)
return
}
if did != h.pds.DID() {
http.Error(w, "repo not found", http.StatusNotFound)
return
}
// List ATProto blobs from storage (S3 prefix listing)
safeDID := strings.ReplaceAll(did, ":", "-")
blobsPath := fmt.Sprintf("/repos/%s/blobs", safeDID)
entries, err := h.storageDriver.List(r.Context(), blobsPath)
if err != nil {
// Path doesn't exist = no blobs, return empty list
render.JSON(w, r, map[string]any{"cids": []string{}})
return
}
cids := make([]string, 0, len(entries))
for _, entry := range entries {
// entry is like "/repos/.../blobs/{cid}" — extract the CID
parts := strings.Split(entry, "/")
if len(parts) > 0 {
cids = append(cids, parts[len(parts)-1])
}
}
render.JSON(w, r, map[string]any{"cids": cids})
}
// HandleGetRepoStatus returns the hosting status for a repository
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status
func (h *XRPCHandler) HandleGetRepoStatus(w http.ResponseWriter, r *http.Request) {
+34 -5
View File
@@ -1600,7 +1600,13 @@ func TestHandleRequestCrew(t *testing.T) {
handler, ctx := setupTestXRPCHandler(t)
// Update captain record to allow all crew
_, err := handler.pds.UpdateCaptainRecord(ctx, true, true, false) // public=true, allowAllCrew=true, enableBlueskyPosts=false
_, captain, err := handler.pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record: %v", err)
}
captain.Public = true
captain.AllowAllCrew = true
_, err = handler.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
t.Fatalf("Failed to update captain record: %v", err)
}
@@ -1644,7 +1650,13 @@ func TestHandleRequestCrew_AllowAllCrewDisabled(t *testing.T) {
// Captain record was created with allowAllCrew=false in setupTestXRPCHandler
// Update to make sure it's false
_, err := handler.pds.UpdateCaptainRecord(ctx, true, false, false) // public=true, allowAllCrew=false, enableBlueskyPosts=false
_, captain, err := handler.pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record: %v", err)
}
captain.Public = true
captain.AllowAllCrew = false
_, err = handler.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
t.Fatalf("Failed to update captain record: %v", err)
}
@@ -1697,7 +1709,13 @@ func TestHandleRequestCrew_WithAuth(t *testing.T) {
handler, ctx := setupTestXRPCHandler(t)
// Update captain record to allow all crew
_, err := handler.pds.UpdateCaptainRecord(ctx, true, true, false) // public=true, allowAllCrew=true
_, captain, err := handler.pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record: %v", err)
}
captain.Public = true
captain.AllowAllCrew = true
_, err = handler.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
t.Fatalf("Failed to update captain record: %v", err)
}
@@ -1793,7 +1811,13 @@ func TestHandleRequestCrew_AlreadyMember(t *testing.T) {
handler, ctx := setupTestXRPCHandler(t)
// Update captain record to allow all crew
_, err := handler.pds.UpdateCaptainRecord(ctx, true, true, false)
_, captain, err := handler.pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record: %v", err)
}
captain.Public = true
captain.AllowAllCrew = true
_, err = handler.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
t.Fatalf("Failed to update captain record: %v", err)
}
@@ -2510,7 +2534,12 @@ func TestHandleGetBlob_CORSHeaders(t *testing.T) {
handler, _, ctx := setupTestXRPCHandlerWithBlobs(t)
// Make hold public
_, err := handler.pds.UpdateCaptainRecord(ctx, true, false, false)
_, captain, err := handler.pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record: %v", err)
}
captain.Public = true
_, err = handler.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
t.Fatalf("Failed to update captain: %v", err)
}
+12
View File
@@ -120,6 +120,18 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
return nil, fmt.Errorf("failed to bootstrap PDS: %w", err)
}
// Sync successor from config (if set) — separate from Bootstrap to avoid changing its signature
if cfg.Server.Successor != "" {
if _, captain, err := s.PDS.GetCaptainRecord(ctx); err == nil && captain.Successor != cfg.Server.Successor {
captain.Successor = cfg.Server.Successor
if _, err := s.PDS.UpdateCaptainRecord(ctx, captain); err != nil {
slog.Warn("Failed to sync successor from config", "error", err)
} else {
slog.Info("Synced successor from config", "successor", cfg.Server.Successor)
}
}
}
// Bootstrap events from existing repo records (one-time migration)
if err := s.broadcaster.BootstrapFromRepo(s.PDS); err != nil {
slog.Warn("Failed to bootstrap events from repo", "error", err)