···97 enabled: false
98# Storage quota tiers. Empty disables quota enforcement.
99quota:
100- # Quota tiers keyed by rank name. Each tier has a human-readable quota limit.
101 tiers:
102- bosun:
103- # Storage quota limit (e.g. "5GB", "50GB", "1TB").
104- quota: 50GB
105- # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
106- scan_on_push: true
107- # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
108- max_webhooks: 5
109- # Allow all webhook trigger types. Free tiers only get scan:first.
110- webhook_all_triggers: true
111- # Show supporter badge on user profiles for members at this tier.
112- supporter_badge: true
113- deckhand:
114- # Storage quota limit (e.g. "5GB", "50GB", "1TB").
115- quota: 5GB
116- # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
117- scan_on_push: false
118- # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
119- max_webhooks: 1
120- # Allow all webhook trigger types. Free tiers only get scan:first.
121- webhook_all_triggers: false
122- # Show supporter badge on user profiles for members at this tier.
123- supporter_badge: true
124- quartermaster:
125- # Storage quota limit (e.g. "5GB", "50GB", "1TB").
126- quota: 100GB
127- # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
128- scan_on_push: true
129- # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
130- max_webhooks: -1
131- # Allow all webhook trigger types. Free tiers only get scan:first.
132- webhook_all_triggers: true
133- # Show supporter badge on user profiles for members at this tier.
134- supporter_badge: true
000135 # Default tier assignment for new crew members.
136 defaults:
137 # Tier assigned to new crew members who don't have an explicit tier.
138 new_crew_tier: deckhand
139 # Show supporter badge on the hold owner's profile.
140- owner_badge: false
141# Vulnerability scanner settings. Empty disables scanning.
142scanner:
143 # Shared secret for scanner WebSocket auth. Empty disables scanning.
···97 enabled: false
98# Storage quota tiers. Empty disables quota enforcement.
99quota:
100+ # Quota tiers ordered by rank (lowest to highest). Position determines rank.
101 tiers:
102+ - # Tier name used as the key for crew assignments.
103+ name: deckhand
104+ # Storage quota limit (e.g. "5GB", "50GB", "1TB").
105+ quota: 5GB
106+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
107+ scan_on_push: false
108+ # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
109+ max_webhooks: 1
110+ # Allow all webhook trigger types. Free tiers only get scan:first.
111+ webhook_all_triggers: false
112+ # Show supporter badge on user profiles for members at this tier.
113+ supporter_badge: true
114+ - # Tier name used as the key for crew assignments.
115+ name: bosun
116+ # Storage quota limit (e.g. "5GB", "50GB", "1TB").
117+ quota: 50GB
118+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
119+ scan_on_push: true
120+ # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
121+ max_webhooks: 5
122+ # Allow all webhook trigger types. Free tiers only get scan:first.
123+ webhook_all_triggers: true
124+ # Show supporter badge on user profiles for members at this tier.
125+ supporter_badge: true
126+ - # Tier name used as the key for crew assignments.
127+ name: quartermaster
128+ # Storage quota limit (e.g. "5GB", "50GB", "1TB").
129+ quota: 100GB
130+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
131+ scan_on_push: true
132+ # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
133+ max_webhooks: -1
134+ # Allow all webhook trigger types. Free tiers only get scan:first.
135+ webhook_all_triggers: true
136+ # Show supporter badge on user profiles for members at this tier.
137+ supporter_badge: true
138 # Default tier assignment for new crew members.
139 defaults:
140 # Tier assigned to new crew members who don't have an explicit tier.
141 new_crew_tier: deckhand
142 # Show supporter badge on the hold owner's profile.
143+ owner_badge: true
144# Vulnerability scanner settings. Empty disables scanning.
145scanner:
146 # Shared secret for scanner WebSocket auth. Empty disables scanning.
···4849 atcr-hold:
50 env_file:
51- - ../atcr-secrets.env # Load S3/Storj credentials from external file
52 # Base config: config-hold.example.yaml (passed via Air entrypoint)
53 # Env vars below override config file values for local dev
54 environment:
055 HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080
56 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
57 HOLD_REGISTRATION_ALLOW_ALL_CREW: true
···4849 atcr-hold:
50 env_file:
51+ - ../atcr-secrets.env # Load S3/Storj credentials from external file
52 # Base config: config-hold.example.yaml (passed via Air entrypoint)
53 # Env vars below override config file values for local dev
54 environment:
55+ HOLD_SCANNER_SECRET: dev-secret
56 HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080
57 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
58 HOLD_REGISTRATION_ALLOW_ALL_CREW: true
+39-10
pkg/appview/db/hold_store.go
···26 AllowAllCrew bool `json:"allowAllCrew"`
27 DeployedAt string `json:"deployedAt"`
28 Region string `json:"region"`
29- Successor string `json:"successor"` // DID of successor hold (migration redirect)
30- SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]'
31- UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
32}
3334// GetCaptainRecord retrieves a captain record from the cache
···135 return false
136}
1370000000000000138// GetSupporterBadge returns the supporter badge tier name for a user on a specific hold.
139// Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible,
140// or the user isn't a member of the hold.
···143 return ""
144 }
145000146 captain, err := GetCaptainRecord(dbConn, holdDID)
147 if err != nil || captain == nil || captain.SupporterBadgeTiers == "" {
148 return ""
149 }
150151- // Check if user is the captain (owner)
152- if captain.OwnerDID == userDID {
153- if captain.HasSupporterBadge("owner") {
154- return "owner"
155- }
156- return ""
157 }
158159 // Look up crew membership for this user on this hold
···163 }
164165 for _, m := range memberships {
166- if m.HoldDID == holdDID && m.Tier != "" {
167 if captain.HasSupporterBadge(m.Tier) {
168 return m.Tier
169 }
···172 }
173174 return ""
0000000000000000175}
176177// ListHoldDIDs returns all known hold DIDs from the cache
···26 AllowAllCrew bool `json:"allowAllCrew"`
27 DeployedAt string `json:"deployedAt"`
28 Region string `json:"region"`
29+ Successor string `json:"successor"` // DID of successor hold (migration redirect)
30+ SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]'
31+ UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
32}
3334// GetCaptainRecord retrieves a captain record from the cache
···135 return false
136}
137138+// normalizeDidWeb ensures did:web DIDs use %3A encoding for port separators.
139+// This is a local copy to avoid importing atproto (prevents circular dependencies).
140+func normalizeDidWeb(did string) string {
141+ if !strings.HasPrefix(did, "did:web:") {
142+ return did
143+ }
144+ host := strings.TrimPrefix(did, "did:web:")
145+ if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
146+ host = strings.Replace(host, ":", "%3A", 1)
147+ }
148+ return "did:web:" + host
149+}
150+151// GetSupporterBadge returns the supporter badge tier name for a user on a specific hold.
152// Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible,
153// or the user isn't a member of the hold.
···156 return ""
157 }
158159+ // Normalize did:web encoding for consistent comparison
160+ holdDID = normalizeDidWeb(holdDID)
161+162 captain, err := GetCaptainRecord(dbConn, holdDID)
163 if err != nil || captain == nil || captain.SupporterBadgeTiers == "" {
164 return ""
165 }
166167+ // If user is the owner and "owner" badge is enabled, show it
168+ if captain.OwnerDID == userDID && captain.HasSupporterBadge("owner") {
169+ return "owner"
000170 }
171172 // Look up crew membership for this user on this hold
···176 }
177178 for _, m := range memberships {
179+ if normalizeDidWeb(m.HoldDID) == holdDID && m.Tier != "" {
180 if captain.HasSupporterBadge(m.Tier) {
181 return m.Tier
182 }
···185 }
186187 return ""
188+}
189+190+// GetCrewHoldDID returns the hold DID from the user's most recent crew membership.
191+// Used as a fallback when the user's DefaultHoldDID is not cached.
192+func GetCrewHoldDID(db DBTX, memberDID string) string {
193+ var holdDID string
194+ err := db.QueryRow(`
195+ SELECT hold_did FROM hold_crew_members
196+ WHERE member_did = ?
197+ ORDER BY updated_at DESC
198+ LIMIT 1
199+ `, memberDID).Scan(&holdDID)
200+ if err != nil {
201+ return ""
202+ }
203+ return holdDID
204}
205206// ListHoldDIDs returns all known hold DIDs from the cache
+70-2
pkg/appview/handlers/settings.go
···25 Region string `json:"region"`
26 Membership string `json:"membership"`
27 Permissions []string `json:"permissions,omitempty"`
028}
2930// SettingsHandler handles the settings page
···82 if hold.Permissions != "" {
83 if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil {
84 slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", user.DID, "hold_did", hold.HoldDID, "error", err)
0000000000085 }
86 }
87···220 holdDID = r.FormValue("hold_endpoint")
221 }
222000223 // Validate hold DID if provided and database is available
224 if holdDID != "" && h.DB != nil {
225 // Check if user has access to this hold
···273 if h.DB != nil {
274 _ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID)
275276- // Refresh captain record for the selected hold so badge tiers are available immediately
0277 if holdDID != "" {
278- go refreshCaptainRecord(holdDID, h.DB)
0000000279 }
280 }
281···334335 slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers)
336}
000000000000000000000000000000000000000000000
···25 Region string `json:"region"`
26 Membership string `json:"membership"`
27 Permissions []string `json:"permissions,omitempty"`
28+ Status string `json:"status"` // "" = unknown, "online", "offline"
29}
3031// SettingsHandler handles the settings page
···83 if hold.Permissions != "" {
84 if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil {
85 slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", user.DID, "hold_did", hold.HoldDID, "error", err)
86+ }
87+ }
88+89+ // Check cached health status (non-blocking, nil = no data yet)
90+ if h.HealthChecker != nil {
91+ if status := h.HealthChecker.GetCachedStatus(hold.HoldDID); status != nil {
92+ if status.Reachable {
93+ display.Status = "online"
94+ } else {
95+ display.Status = "offline"
96+ }
97 }
98 }
99···232 holdDID = r.FormValue("hold_endpoint")
233 }
234235+ // Normalize did:web encoding (form URL-decoding can strip %3A → colon)
236+ holdDID = atproto.NormalizeDID(holdDID)
237+238 // Validate hold DID if provided and database is available
239 if holdDID != "" && h.DB != nil {
240 // Check if user has access to this hold
···288 if h.DB != nil {
289 _ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID)
290291+ // Ensure crew membership on the new hold (auto-registers on open holds)
292+ // and refresh captain/crew cache so badge tiers are available immediately
293 if holdDID != "" {
294+ go func() {
295+ storage.EnsureCrewMembership(
296+ context.Background(), client, h.Refresher,
297+ holdDID, middleware.GetGlobalAuthorizer(),
298+ )
299+ refreshCaptainRecord(holdDID, h.DB)
300+ refreshCrewMembership(holdDID, user.DID, h.DB)
301+ }()
302 }
303 }
304···357358 slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers)
359}
360+361+// refreshCrewMembership fetches a user's crew record from a hold and caches it locally.
362+// Uses the deterministic rkey to do a direct O(1) lookup.
363+func refreshCrewMembership(holdDID, userDID string, dbConn *sql.DB) {
364+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
365+ defer cancel()
366+367+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
368+ if err != nil {
369+ slog.Debug("Failed to resolve hold URL for crew refresh", "hold_did", holdDID, "error", err)
370+ return
371+ }
372+373+ rkey := atproto.CrewRecordKey(userDID)
374+ holdClient := atproto.NewClient(holdURL, holdDID, "")
375+ record, err := holdClient.GetRecord(ctx, atproto.CrewCollection, rkey)
376+ if err != nil {
377+ slog.Debug("No crew record found for user on hold", "hold_did", holdDID, "user_did", userDID, "error", err)
378+ return
379+ }
380+381+ var crewRecord atproto.CrewRecord
382+ if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
383+ slog.Debug("Failed to parse crew record for refresh", "hold_did", holdDID, "error", err)
384+ return
385+ }
386+387+ permJSON, _ := json.Marshal(crewRecord.Permissions)
388+ member := &db.CrewMember{
389+ HoldDID: holdDID,
390+ MemberDID: crewRecord.Member,
391+ Rkey: rkey,
392+ Role: crewRecord.Role,
393+ Permissions: string(permJSON),
394+ Tier: crewRecord.Tier,
395+ AddedAt: crewRecord.AddedAt,
396+ }
397+398+ if err := db.UpsertCrewMember(dbConn, member); err != nil {
399+ slog.Debug("Failed to cache crew membership on refresh", "hold_did", holdDID, "user_did", userDID, "error", err)
400+ return
401+ }
402+403+ slog.Info("Refreshed crew membership for user on hold", "hold_did", holdDID, "user_did", userDID, "tier", crewRecord.Tier)
404+}
+9-2
pkg/appview/handlers/user.go
···6465 // Check for supporter badge on user's default hold
66 var supporterBadge string
67- if hasProfile && h.ReadOnlyDB != nil && viewedUser.DefaultHoldDID != "" {
68- supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, viewedUser.DefaultHoldDID)
000000069 }
7071 // Build page meta
···6465 // Check for supporter badge on user's default hold
66 var supporterBadge string
67+ if h.ReadOnlyDB != nil {
68+ holdDID := viewedUser.DefaultHoldDID
69+ if holdDID == "" {
70+ // Fallback: check if user has any crew membership
71+ holdDID = db.GetCrewHoldDID(h.ReadOnlyDB, viewedUser.DID)
72+ }
73+ if holdDID != "" {
74+ supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, holdDID)
75+ }
76 }
7778 // Build page meta
···155 {{ if .OwnedHolds }}
156 <optgroup label="Your Holds">
157 {{ range .OwnedHolds }}
158- <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
159- {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
160 </option>
161 {{ end }}
162 </optgroup>
···165 {{ if .CrewHolds }}
166 <optgroup label="Crew Member">
167 {{ range .CrewHolds }}
168- <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
169- {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
170 </option>
171 {{ end }}
172 </optgroup>
···175 {{ if .EligibleHolds }}
176 <optgroup label="Open Registration">
177 {{ range .EligibleHolds }}
178- <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
179- {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
180 </option>
181 {{ end }}
182 </optgroup>
···199 <dd id="hold-did" class="font-mono"></dd>
200 <dt class="text-base-content/70">Region:</dt>
201 <dd id="hold-region"></dd>
00202 <dt class="text-base-content/70">Your Access:</dt>
203 <dd id="hold-access"></dd>
204 </dl>
···407408 document.getElementById('hold-did').textContent = hold.did;
409 document.getElementById('hold-region').textContent = hold.region || 'Unknown';
0000000000410411 // Set access level with badge
412 const accessEl = document.getElementById('hold-access');
···155 {{ if .OwnedHolds }}
156 <optgroup label="Your Holds">
157 {{ range .OwnedHolds }}
158+ <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
159+ {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
160 </option>
161 {{ end }}
162 </optgroup>
···165 {{ if .CrewHolds }}
166 <optgroup label="Crew Member">
167 {{ range .CrewHolds }}
168+ <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
169+ {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
170 </option>
171 {{ end }}
172 </optgroup>
···175 {{ if .EligibleHolds }}
176 <optgroup label="Open Registration">
177 {{ range .EligibleHolds }}
178+ <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
179+ {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
180 </option>
181 {{ end }}
182 </optgroup>
···199 <dd id="hold-did" class="font-mono"></dd>
200 <dt class="text-base-content/70">Region:</dt>
201 <dd id="hold-region"></dd>
202+ <dt class="text-base-content/70">Status:</dt>
203+ <dd id="hold-status-badge"></dd>
204 <dt class="text-base-content/70">Your Access:</dt>
205 <dd id="hold-access"></dd>
206 </dl>
···409410 document.getElementById('hold-did').textContent = hold.did;
411 document.getElementById('hold-region').textContent = hold.region || 'Unknown';
412+413+ // Set status badge
414+ const statusEl = document.getElementById('hold-status-badge');
415+ if (hold.status === 'offline') {
416+ statusEl.innerHTML = '<span class="badge badge-sm badge-warning">Offline</span>';
417+ } else if (hold.status === 'online') {
418+ statusEl.innerHTML = '<span class="badge badge-sm badge-success">Online</span>';
419+ } else {
420+ statusEl.innerHTML = '<span class="text-base-content/60">Unknown</span>';
421+ }
422423 // Set access level with badge
424 const accessEl = document.getElementById('hold-access');
+1-1
pkg/appview/templates/partials/webhooks_list.html
···28 <legend class="label"><span class="label-text">Trigger Events</span></legend>
29 <div class="space-y-2 mt-1">
30 {{ range .TriggerInfo }}
31- <label class="flex items-start gap-3 cursor-pointer{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50{{ end }}">
32 <input type="checkbox" name="trigger_{{ if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}"
33 class="checkbox checkbox-sm mt-0.5"
34 {{ if .AlwaysAvailable }}checked{{ end }}
···28 <legend class="label"><span class="label-text">Trigger Events</span></legend>
29 <div class="space-y-2 mt-1">
30 {{ range .TriggerInfo }}
31+ <label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}">
32 <input type="checkbox" name="trigger_{{ if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}"
33 class="checkbox checkbox-sm mt-0.5"
34 {{ if .AlwaysAvailable }}checked{{ end }}
+6-6
pkg/atproto/lexicon.go
···667// Stored in the hold's embedded PDS to identify the hold owner and settings
668// Uses CBOR encoding for efficient storage in hold's carstore
669type CaptainRecord struct {
670- Type string `json:"$type" cborgen:"$type"`
671- Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
672- Public bool `json:"public" cborgen:"public"` // Public read access
673- AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
674- EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
675- DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
676 Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
677 Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
678 SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles
···667// Stored in the hold's embedded PDS to identify the hold owner and settings
668// Uses CBOR encoding for efficient storage in hold's carstore
669type CaptainRecord struct {
670+ Type string `json:"$type" cborgen:"$type"`
671+ Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
672+ Public bool `json:"public" cborgen:"public"` // Public read access
673+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
674+ EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
675+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
676 Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
677 Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
678 SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles
+1
pkg/atproto/relays.go
···33 {Name: "Hayes", URL: "https://relay.hayescmd.net"},
34 {Name: "Xero", URL: "https://relay.xero.systems"},
35 {Name: "Feeds Blue", URL: "https://relay.feeds.blue"},
036}
3738// RelayHTTPError indicates the relay responded with a non-200 status code.
···33 {Name: "Hayes", URL: "https://relay.hayescmd.net"},
34 {Name: "Xero", URL: "https://relay.xero.systems"},
35 {Name: "Feeds Blue", URL: "https://relay.feeds.blue"},
36+ {Name: "Waow", URL: "https://relay.waow.tech"},
37}
3839// RelayHTTPError indicates the relay responded with a non-200 status code.
+15
pkg/atproto/resolver.go
···118 return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
119}
120000000000000000121// didWebToURL converts a did:web DID to its base URL.
122// did:web:example.com → https://example.com
123// did:web:172.28.0.3%3A8080 → http://172.28.0.3:8080
···118 return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
119}
120121+// NormalizeDID ensures did:web DIDs use %3A encoding for port separators
122+// per the did:web spec. Other DID methods are returned as-is.
123+// e.g., "did:web:172.28.0.3:8080" → "did:web:172.28.0.3%3A8080"
124+func NormalizeDID(did string) string {
125+ if !strings.HasPrefix(did, "did:web:") {
126+ return did
127+ }
128+ host := strings.TrimPrefix(did, "did:web:")
129+ // Only fix bare colons — skip if already percent-encoded
130+ if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
131+ host = strings.Replace(host, ":", "%3A", 1)
132+ }
133+ return "did:web:" + host
134+}
135+136// didWebToURL converts a did:web DID to its base URL.
137// did:web:example.com → https://example.com
138// did:web:172.28.0.3%3A8080 → http://172.28.0.3:8080
···512 pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
513514 // Add crew member with blob:write permission
515- _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
516 if err != nil {
517 t.Fatalf("Failed to add crew member: %v", err)
518 }
···565 pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
566567 // Add crew member with blob:read permission only (no blob:write)
568- _, err := pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
569 if err != nil {
570 t.Fatalf("Failed to add crew member: %v", err)
571 }
···645646 // Add crew member with blob:write permission
647 writerDID := "did:plc:writer123"
648- _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
649 if err != nil {
650 t.Fatalf("Failed to add crew member: %v", err)
651 }
652653 // Add crew member without blob:write permission
654 readerDID := "did:plc:reader123"
655- _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
656 if err != nil {
657 t.Fatalf("Failed to add crew member: %v", err)
658 }
···796797 // Add crew member with ONLY blob:write permission (no blob:read)
798 writerDID := "did:plc:writer123"
799- _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
800 if err != nil {
801 t.Fatalf("Failed to add crew writer: %v", err)
802 }
···831 // Also verify that crew with only blob:read still works
832 t.Run("crew with blob:read can read", func(t *testing.T) {
833 readerDID := "did:plc:reader123"
834- _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
835 if err != nil {
836 t.Fatalf("Failed to add crew reader: %v", err)
837 }
···861 // Verify crew with neither permission cannot read
862 t.Run("crew without read or write cannot read", func(t *testing.T) {
863 noPermDID := "did:plc:noperm123"
864- _, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"})
865 if err != nil {
866 t.Fatalf("Failed to add crew member: %v", err)
867 }
···896897 // Add crew member with crew:admin permission
898 adminDID := "did:plc:admin123"
899- _, err := pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"})
900 if err != nil {
901 t.Fatalf("Failed to add crew admin: %v", err)
902 }
903904 // Add crew member without crew:admin permission
905 writerDID := "did:plc:writer123"
906- _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
907 if err != nil {
908 t.Fatalf("Failed to add crew writer: %v", err)
909 }
···990991 // Add all crew members
992 for _, tt := range tests {
993- _, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions)
994 if err != nil {
995 t.Fatalf("Failed to add crew member %s: %v", tt.name, err)
996 }
···512 pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
513514 // Add crew member with blob:write permission
515+ _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
516 if err != nil {
517 t.Fatalf("Failed to add crew member: %v", err)
518 }
···565 pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
566567 // Add crew member with blob:read permission only (no blob:write)
568+ _, err := pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "")
569 if err != nil {
570 t.Fatalf("Failed to add crew member: %v", err)
571 }
···645646 // Add crew member with blob:write permission
647 writerDID := "did:plc:writer123"
648+ _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
649 if err != nil {
650 t.Fatalf("Failed to add crew member: %v", err)
651 }
652653 // Add crew member without blob:write permission
654 readerDID := "did:plc:reader123"
655+ _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "")
656 if err != nil {
657 t.Fatalf("Failed to add crew member: %v", err)
658 }
···796797 // Add crew member with ONLY blob:write permission (no blob:read)
798 writerDID := "did:plc:writer123"
799+ _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
800 if err != nil {
801 t.Fatalf("Failed to add crew writer: %v", err)
802 }
···831 // Also verify that crew with only blob:read still works
832 t.Run("crew with blob:read can read", func(t *testing.T) {
833 readerDID := "did:plc:reader123"
834+ _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "")
835 if err != nil {
836 t.Fatalf("Failed to add crew reader: %v", err)
837 }
···861 // Verify crew with neither permission cannot read
862 t.Run("crew without read or write cannot read", func(t *testing.T) {
863 noPermDID := "did:plc:noperm123"
864+ _, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"}, "")
865 if err != nil {
866 t.Fatalf("Failed to add crew member: %v", err)
867 }
···896897 // Add crew member with crew:admin permission
898 adminDID := "did:plc:admin123"
899+ _, err := pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"}, "")
900 if err != nil {
901 t.Fatalf("Failed to add crew admin: %v", err)
902 }
903904 // Add crew member without crew:admin permission
905 writerDID := "did:plc:writer123"
906+ _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
907 if err != nil {
908 t.Fatalf("Failed to add crew writer: %v", err)
909 }
···990991 // Add all crew members
992 for _, tt := range tests {
993+ _, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions, "")
994 if err != nil {
995 t.Fatalf("Failed to add crew member %s: %v", tt.name, err)
996 }
+2-1
pkg/hold/pds/crew.go
···17// AddCrewMember adds a new crew member to the hold and commits to carstore
18// Uses deterministic rkey based on member DID hash for O(1) lookups and automatic deduplication
19// If the member already exists, updates their record (upsert behavior)
20-func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
21 crewRecord := &atproto.CrewRecord{
22 Type: atproto.CrewCollection,
23 Member: memberDID,
24 Role: role,
25 Permissions: permissions,
026 AddedAt: time.Now().Format(time.RFC3339),
27 }
28
···17// AddCrewMember adds a new crew member to the hold and commits to carstore
18// Uses deterministic rkey based on member DID hash for O(1) lookups and automatic deduplication
19// If the member already exists, updates their record (upsert behavior)
20+func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string, tier string) (cid.Cid, error) {
21 crewRecord := &atproto.CrewRecord{
22 Type: atproto.CrewCollection,
23 Member: memberDID,
24 Role: role,
25 Permissions: permissions,
26+ Tier: tier,
27 AddedAt: time.Now().Format(time.RFC3339),
28 }
29
+8-8
pkg/hold/pds/crew_test.go
···18 role := "writer"
19 permissions := []string{"blob:read", "blob:write"}
2021- recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions)
22 if err != nil {
23 t.Fatalf("AddCrewMember failed: %v", err)
24 }
···71 role := "reader"
72 permissions := []string{"blob:read"}
7374- _, err := pds.AddCrewMember(ctx, memberDID, role, permissions)
75 if err != nil {
76 t.Fatalf("AddCrewMember failed: %v", err)
77 }
···174 }
175176 for _, m := range members {
177- _, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions)
178 if err != nil {
179 t.Fatalf("AddCrewMember failed for %s: %v", m.did, err)
180 }
···230231 // Add crew member
232 memberDID := "did:plc:alice123"
233- _, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read", "blob:write"})
234 if err != nil {
235 t.Fatalf("AddCrewMember failed: %v", err)
236 }
···301 }
302303 for _, did := range dids {
304- _, err := pds.AddCrewMember(ctx, did, "writer", []string{"blob:read"})
305 if err != nil {
306 t.Fatalf("AddCrewMember failed for %s: %v", did, err)
307 }
···447448 // Add crew member
449 memberDID := "did:plc:alice123"
450- _, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read"})
451 if err != nil {
452 t.Fatalf("AddCrewMember failed: %v", err)
453 }
···489 role := "writer"
490 permissions := []string{"blob:read", "blob:write"}
491492- recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions)
493 if err != nil {
494 t.Fatalf("AddCrewMember failed with did:web: %v", err)
495 }
···553 }
554555 for _, m := range members {
556- _, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions)
557 if err != nil {
558 t.Fatalf("AddCrewMember failed for %s: %v", m.did, err)
559 }
···18 role := "writer"
19 permissions := []string{"blob:read", "blob:write"}
2021+ recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "")
22 if err != nil {
23 t.Fatalf("AddCrewMember failed: %v", err)
24 }
···71 role := "reader"
72 permissions := []string{"blob:read"}
7374+ _, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "")
75 if err != nil {
76 t.Fatalf("AddCrewMember failed: %v", err)
77 }
···174 }
175176 for _, m := range members {
177+ _, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions, "")
178 if err != nil {
179 t.Fatalf("AddCrewMember failed for %s: %v", m.did, err)
180 }
···230231 // Add crew member
232 memberDID := "did:plc:alice123"
233+ _, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read", "blob:write"}, "")
234 if err != nil {
235 t.Fatalf("AddCrewMember failed: %v", err)
236 }
···301 }
302303 for _, did := range dids {
304+ _, err := pds.AddCrewMember(ctx, did, "writer", []string{"blob:read"}, "")
305 if err != nil {
306 t.Fatalf("AddCrewMember failed for %s: %v", did, err)
307 }
···447448 // Add crew member
449 memberDID := "did:plc:alice123"
450+ _, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read"}, "")
451 if err != nil {
452 t.Fatalf("AddCrewMember failed: %v", err)
453 }
···489 role := "writer"
490 permissions := []string{"blob:read", "blob:write"}
491492+ recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "")
493 if err != nil {
494 t.Fatalf("AddCrewMember failed with did:web: %v", err)
495 }
···553 }
554555 for _, m := range members {
556+ _, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions, "")
557 if err != nil {
558 t.Fatalf("AddCrewMember failed for %s: %v", m.did, err)
559 }
···288 "region", cfg.Region)
289290 // Add hold owner as first crew member with admin role
291- _, err = p.AddCrewMember(ctx, cfg.OwnerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
292 if err != nil {
293 return fmt.Errorf("failed to add owner as crew member: %w", err)
294 }
···288 "region", cfg.Region)
289290 // Add hold owner as first crew member with admin role
291+ _, err = p.AddCrewMember(ctx, cfg.OwnerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}, "")
292 if err != nil {
293 return fmt.Errorf("failed to add owner as crew member: %w", err)
294 }