implement hold discovery dropdown in settings. implement a data privacy export feature

This commit is contained in:
Evan Jarrett
2026-01-07 22:41:14 -06:00
parent d4b88b5105
commit 3409af6c67
39 changed files with 4124 additions and 159 deletions
+18
View File
@@ -7,8 +7,10 @@ package hold
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
@@ -54,6 +56,9 @@ type RegistrationConfig struct {
// If true, creates posts when users push images
// Synced to captain record's enableBlueskyPosts field on startup
EnableBlueskyPosts bool `yaml:"enable_bluesky_posts"`
// Region is the deployment region, auto-detected from cloud metadata or S3 config
Region string `yaml:"region"`
}
// StorageConfig wraps distribution's storage configuration
@@ -148,6 +153,18 @@ func LoadConfigFromEnv() (*Config, error) {
// Admin panel configuration
cfg.Admin.Enabled = os.Getenv("HOLD_ADMIN_ENABLED") == "true"
// Detect region from cloud metadata or S3 config
if meta, err := DetectCloudMetadata(context.Background()); err == nil && meta != nil {
cfg.Registration.Region = meta.Region
slog.Info("Detected cloud metadata", "region", meta.Region)
} else {
// Fall back to S3 region
if storageType == "s3" {
cfg.Registration.Region = getEnvOrDefault("AWS_REGION", "us-east-1")
slog.Info("Using S3 region", "region", cfg.Registration.Region)
}
}
return cfg, nil
}
@@ -200,6 +217,7 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue
}
// RequestCrawl sends a crawl request to the ATProto relay for the given hostname.
// This makes the hold's PDS discoverable by the relay network.
func RequestCrawl(relayEndpoint, publicURL string) error {
+65
View File
@@ -0,0 +1,65 @@
package hold
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// CloudMetadata contains region info from cloud metadata service
type CloudMetadata struct {
Region string
}
// DetectCloudMetadata queries the instance metadata service (169.254.169.254)
// Currently supports UpCloud. Others can be added via PR.
func DetectCloudMetadata(ctx context.Context) (*CloudMetadata, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// Try UpCloud metadata format
if meta, err := detectUpCloud(ctx); err == nil {
return meta, nil
}
// Add other providers here (AWS, GCP, Azure, DigitalOcean, etc.)
// Contributors welcome!
return nil, nil // No metadata available
}
// detectUpCloud queries UpCloud's metadata service
func detectUpCloud(ctx context.Context) (*CloudMetadata, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://169.254.169.254/metadata/v1.json", nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("metadata returned %d", resp.StatusCode)
}
var data struct {
CloudName string `json:"cloud_name"`
Region string `json:"region"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}
if data.CloudName != "upcloud" {
return nil, fmt.Errorf("not upcloud: %s", data.CloudName)
}
return &CloudMetadata{
Region: data.Region,
}, nil
}
+1 -1
View File
@@ -111,7 +111,7 @@ func setupTestOCIHandler(t *testing.T) (*XRPCHandler, context.Context) {
r, w, _ := os.Pipe()
os.Stdout = w
err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
// Restore stdout
w.Close()
+2 -1
View File
@@ -17,7 +17,7 @@ const (
// CreateCaptainRecord creates the captain record for the hold (first-time only).
// This will FAIL if the captain record already exists. Use UpdateCaptainRecord to modify.
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) {
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool, region string) (cid.Cid, error) {
captainRecord := &atproto.CaptainRecord{
Type: atproto.CaptainCollection,
Owner: ownerDID,
@@ -25,6 +25,7 @@ func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, publ
AllowAllCrew: allowAllCrew,
EnableBlueskyPosts: enableBlueskyPosts,
DeployedAt: time.Now().Format(time.RFC3339),
Region: region,
}
// Use repomgr.PutRecord - creates with explicit rkey, fails if already exists
+4 -9
View File
@@ -55,7 +55,7 @@ func setupTestPDSWithBootstrap(t *testing.T, ownerDID string, public, allowAllCr
r, w, _ := os.Pipe()
os.Stdout = w
err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "")
err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "")
w.Close()
os.Stdout = oldStdout
@@ -114,7 +114,7 @@ func TestCreateCaptainRecord(t *testing.T) {
defer pds.Close()
// Create captain record
recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew, tt.enableBlueskyPosts)
recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew, tt.enableBlueskyPosts, "")
if err != nil {
t.Fatalf("CreateCaptainRecord failed: %v", err)
}
@@ -164,7 +164,7 @@ func TestGetCaptainRecord(t *testing.T) {
ownerDID := "did:plc:alice123"
// Create captain record
createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false, false)
createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false, false, "")
if err != nil {
t.Fatalf("CreateCaptainRecord failed: %v", err)
}
@@ -221,7 +221,7 @@ func TestUpdateCaptainRecord(t *testing.T) {
ownerDID := "did:plc:alice123"
// Create initial captain record (public=false, allowAllCrew=false, enableBlueskyPosts=false)
_, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false, false)
_, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false, false, "")
if err != nil {
t.Fatalf("CreateCaptainRecord failed: %v", err)
}
@@ -343,7 +343,6 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
AllowAllCrew: true,
DeployedAt: "2025-10-16T12:00:00Z",
Region: "us-west-2",
Provider: "fly.io",
},
},
{
@@ -355,7 +354,6 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
AllowAllCrew: true,
DeployedAt: "2025-10-16T12:00:00Z",
Region: "",
Provider: "",
},
},
}
@@ -400,9 +398,6 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
if decoded.Region != tt.record.Region {
t.Errorf("Region mismatch: expected %s, got %s", tt.record.Region, decoded.Region)
}
if decoded.Provider != tt.record.Provider {
t.Errorf("Provider mismatch: expected %s, got %s", tt.record.Provider, decoded.Provider)
}
})
}
}
+82
View File
@@ -212,3 +212,85 @@ func (p *HoldPDS) getCrewTier(ctx context.Context, userDID string) string {
return ""
}
// ListLayerRecordsForUser returns all layer records uploaded by a specific user
// Used for GDPR data export to return all layers a user has pushed to this hold
func (p *HoldPDS) ListLayerRecordsForUser(ctx context.Context, userDID string) ([]*atproto.LayerRecord, error) {
if p.recordsIndex == nil {
return nil, fmt.Errorf("records index not available")
}
// Get session for reading record data
session, err := p.carstore.ReadOnlySession(p.uid)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
if err != nil {
return nil, fmt.Errorf("failed to get repo head: %w", err)
}
if !head.Defined() {
// Empty repo - return empty list
return []*atproto.LayerRecord{}, nil
}
repoHandle, err := repo.OpenRepo(ctx, session, head)
if err != nil {
return nil, fmt.Errorf("failed to open repo: %w", err)
}
var records []*atproto.LayerRecord
// Iterate all layer records via the index
cursor := ""
batchSize := 1000 // Process in batches
for {
indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true)
if err != nil {
return nil, fmt.Errorf("failed to list layer records: %w", err)
}
for _, rec := range indexRecords {
// Construct record path and get the record data
recordPath := rec.Collection + "/" + rec.Rkey
_, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath)
if err != nil {
// Skip records we can't read
continue
}
// Decode the layer record
recordValue, err := lexutil.CborDecodeValue(*recBytes)
if err != nil {
continue
}
layerRecord, ok := recordValue.(*atproto.LayerRecord)
if !ok {
continue
}
// Filter by userDID
if layerRecord.UserDID != userDID {
continue
}
records = append(records, layerRecord)
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
if records == nil {
records = []*atproto.LayerRecord{}
}
return records, nil
}
+1 -1
View File
@@ -308,7 +308,7 @@ func setupTestPDSWithIndex(t *testing.T, ownerDID string) (*HoldPDS, func()) {
}
// Bootstrap with owner
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
+4 -3
View File
@@ -153,7 +153,7 @@ func (p *HoldPDS) UID() models.Uid {
}
// Bootstrap initializes the hold with the captain record, owner as first crew member, and profile
func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL string) error {
func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error {
if ownerDID == "" {
return nil
}
@@ -185,7 +185,7 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
}
// Create captain record (hold ownership and settings)
_, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts)
_, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts, region)
if err != nil {
return fmt.Errorf("failed to create captain record: %w", err)
}
@@ -193,7 +193,8 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
slog.Info("Created captain record",
"public", public,
"allowAllCrew", allowAllCrew,
"enableBlueskyPosts", p.enableBlueskyPosts)
"enableBlueskyPosts", p.enableBlueskyPosts,
"region", region)
// Add hold owner as first crew member with admin role
_, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
+13 -13
View File
@@ -69,7 +69,7 @@ func TestNewHoldPDS_ExistingRepo(t *testing.T) {
// Bootstrap with a captain record
ownerDID := "did:plc:owner123"
if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -129,7 +129,7 @@ func TestBootstrap_NewRepo(t *testing.T) {
publicAccess := true
allowAllCrew := false
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "")
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -204,7 +204,7 @@ func TestBootstrap_Idempotent(t *testing.T) {
ownerDID := "did:plc:alice123"
// First bootstrap
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("First bootstrap failed: %v", err)
}
@@ -223,7 +223,7 @@ func TestBootstrap_Idempotent(t *testing.T) {
crewCount1 := len(crew1)
// Second bootstrap (should be idempotent - skip creation)
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("Second bootstrap failed: %v", err)
}
@@ -268,7 +268,7 @@ func TestBootstrap_EmptyOwner(t *testing.T) {
defer pds.Close()
// Bootstrap with empty owner DID (should be no-op)
err = pds.Bootstrap(ctx, nil, "", true, false, "")
err = pds.Bootstrap(ctx, nil, "", true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap with empty owner should not error: %v", err)
}
@@ -302,7 +302,7 @@ func TestLexiconTypeRegistration(t *testing.T) {
// Bootstrap to create captain record
ownerDID := "did:plc:alice123"
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -355,7 +355,7 @@ func TestBootstrap_DidWebOwner(t *testing.T) {
publicAccess := true
allowAllCrew := false
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "")
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "")
if err != nil {
t.Fatalf("Bootstrap failed with did:web owner: %v", err)
}
@@ -414,7 +414,7 @@ func TestBootstrap_MixedDIDs(t *testing.T) {
// Bootstrap with did:plc owner
plcOwner := "did:plc:alice123"
err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "")
err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -509,7 +509,7 @@ func TestBootstrap_CrewWithoutCaptain(t *testing.T) {
}
// Bootstrap should create captain record
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -559,7 +559,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) {
// Create captain record WITHOUT crew (unusual state)
ownerDID := "did:plc:alice123"
_, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false)
_, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false, "")
if err != nil {
t.Fatalf("CreateCaptainRecord failed: %v", err)
}
@@ -584,7 +584,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) {
// Bootstrap should be idempotent but notice missing crew
// Currently Bootstrap skips if captain exists, so crew won't be added
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -856,7 +856,7 @@ func TestHoldPDS_BackfillRecordsIndex(t *testing.T) {
// Bootstrap to create some records in MST (captain + crew)
ownerDID := "did:plc:testowner"
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -921,7 +921,7 @@ func TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced(t *testing.T) {
defer pds.Close()
// Bootstrap to create records
err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "")
err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
+23
View File
@@ -216,3 +216,26 @@ func (p *HoldPDS) ListStats(ctx context.Context) ([]*atproto.StatsRecord, error)
return stats, nil
}
// ListStatsRecordsForUser returns all stats records where the user is the repository owner
// Used for GDPR data export to return all stats for repositories owned by the user
func (p *HoldPDS) ListStatsRecordsForUser(ctx context.Context, userDID string) ([]*atproto.StatsRecord, error) {
// Get all stats records and filter by ownerDID
allStats, err := p.ListStats(ctx)
if err != nil {
return nil, err
}
var userStats []*atproto.StatsRecord
for _, stat := range allStats {
if stat.OwnerDID == userDID {
userStats = append(userStats, stat)
}
}
if userStats == nil {
userStats = []*atproto.StatsRecord{}
}
return userStats, nil
}
+1 -1
View File
@@ -277,7 +277,7 @@ func TestMain(m *testing.M) {
// Bootstrap once
ownerDID := "did:plc:testowner123"
err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "")
err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "", "")
if err != nil {
panic(fmt.Sprintf("Failed to bootstrap shared PDS: %v", err))
}
+138
View File
@@ -195,6 +195,8 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(h.requireAuth)
r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew)
// GDPR data export endpoint (TODO: implement)
r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData)
})
// Public quota endpoint (no auth - quota is per-user, just needs userDid param)
@@ -1492,3 +1494,139 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, stats)
}
// HoldUserDataExport represents the GDPR data export from a hold service
type HoldUserDataExport struct {
ExportedAt time.Time `json:"exported_at"`
HoldDID string `json:"hold_did"`
UserDID string `json:"user_did"`
IsCaptain bool `json:"is_captain"`
CrewRecord *CrewExport `json:"crew_record,omitempty"`
LayerRecords []LayerExport `json:"layer_records"`
StatsRecords []StatsExport `json:"stats_records"`
}
// CrewExport represents a sanitized crew record for export
type CrewExport struct {
Role string `json:"role"`
Permissions []string `json:"permissions"`
Tier string `json:"tier,omitempty"`
AddedAt string `json:"added_at"`
}
// LayerExport represents a layer record for export
type LayerExport struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
MediaType string `json:"media_type"`
Manifest string `json:"manifest"`
CreatedAt string `json:"created_at"`
}
// StatsExport represents a stats record for export
type StatsExport struct {
Repository string `json:"repository"`
PullCount int64 `json:"pull_count"`
PushCount int64 `json:"push_count"`
LastPull string `json:"last_pull,omitempty"`
LastPush string `json:"last_push,omitempty"`
UpdatedAt string `json:"updated_at"`
}
// HandleExportUserData handles GDPR data export requests for a specific user.
// This endpoint returns all records stored on this hold's PDS that reference
// the authenticated user's DID.
//
// Returns:
// - io.atcr.hold.layer records where userDid matches
// - io.atcr.hold.crew record for the DID (if exists)
// - io.atcr.hold.stats records where ownerDid matches
// - Whether the user is the hold captain
//
// Authentication: Requires valid service token from user's PDS
func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Request) {
// Get authenticated user from context
user := getUserFromContext(r)
if user == nil {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
slog.Info("GDPR data export requested",
"requester_did", user.DID,
"hold_did", h.pds.DID())
export := HoldUserDataExport{
ExportedAt: time.Now().UTC(),
HoldDID: h.pds.DID(),
UserDID: user.DID,
LayerRecords: []LayerExport{},
StatsRecords: []StatsExport{},
}
// Check if user is captain
_, captain, err := h.pds.GetCaptainRecord(r.Context())
if err == nil && captain != nil && captain.Owner == user.DID {
export.IsCaptain = true
}
// Get crew record for user
_, crewRecord, err := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
if err == nil && crewRecord != nil {
export.CrewRecord = &CrewExport{
Role: crewRecord.Role,
Permissions: crewRecord.Permissions,
Tier: crewRecord.Tier,
AddedAt: crewRecord.AddedAt,
}
}
// Get layer records for user
layerRecords, err := h.pds.ListLayerRecordsForUser(r.Context(), user.DID)
if err != nil {
slog.Warn("Failed to get layer records for export",
"user_did", user.DID,
"error", err)
// Continue with empty list - don't fail entire export
} else {
for _, layer := range layerRecords {
export.LayerRecords = append(export.LayerRecords, LayerExport{
Digest: layer.Digest,
Size: layer.Size,
MediaType: layer.MediaType,
Manifest: layer.Manifest,
CreatedAt: layer.CreatedAt,
})
}
}
// Get stats records for user
statsRecords, err := h.pds.ListStatsRecordsForUser(r.Context(), user.DID)
if err != nil {
slog.Warn("Failed to get stats records for export",
"user_did", user.DID,
"error", err)
// Continue with empty list - don't fail entire export
} else {
for _, stat := range statsRecords {
export.StatsRecords = append(export.StatsRecords, StatsExport{
Repository: stat.Repository,
PullCount: stat.PullCount,
PushCount: stat.PushCount,
LastPull: stat.LastPull,
LastPush: stat.LastPush,
UpdatedAt: stat.UpdatedAt,
})
}
}
slog.Info("GDPR data export completed",
"user_did", user.DID,
"hold_did", h.pds.DID(),
"is_captain", export.IsCaptain,
"has_crew_record", export.CrewRecord != nil,
"layer_count", len(export.LayerRecords),
"stats_count", len(export.StatsRecords))
render.JSON(w, r, export)
}
+4 -4
View File
@@ -58,7 +58,7 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
r, w, _ := os.Pipe()
os.Stdout = w
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
// Restore stdout
w.Close()
@@ -116,7 +116,7 @@ func setupTestXRPCHandlerWithIndex(t *testing.T) (*XRPCHandler, context.Context)
r, w, _ := os.Pipe()
os.Stdout = w
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
// Restore stdout
w.Close()
@@ -1986,7 +1986,7 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service,
r, w, _ := os.Pipe()
os.Stdout = w
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
// Restore stdout
w.Close()
@@ -2429,7 +2429,7 @@ func TestRequireOwnerOrCrewAdmin_Authorized(t *testing.T) {
// Clean up - recreate captain record if it was deleted
if w.Code == http.StatusOK {
handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "")
handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "", "")
}
}