mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 16:54:15 +00:00
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:
@@ -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/
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
®ion,
|
||||
&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,
|
||||
®ion,
|
||||
&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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
|
||||
@@ -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": [{...}]}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
®ion,
|
||||
&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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user