···9797 enabled: false
9898# Storage quota tiers. Empty disables quota enforcement.
9999quota:
100100- # Quota tiers keyed by rank name. Each tier has a human-readable quota limit.
100100+ # Quota tiers ordered by rank (lowest to highest). Position determines rank.
101101 tiers:
102102- bosun:
103103- # Storage quota limit (e.g. "5GB", "50GB", "1TB").
104104- quota: 50GB
105105- # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
106106- scan_on_push: true
107107- # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
108108- max_webhooks: 5
109109- # Allow all webhook trigger types. Free tiers only get scan:first.
110110- webhook_all_triggers: true
111111- # Show supporter badge on user profiles for members at this tier.
112112- supporter_badge: true
113113- deckhand:
114114- # Storage quota limit (e.g. "5GB", "50GB", "1TB").
115115- quota: 5GB
116116- # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
117117- scan_on_push: false
118118- # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
119119- max_webhooks: 1
120120- # Allow all webhook trigger types. Free tiers only get scan:first.
121121- webhook_all_triggers: false
122122- # Show supporter badge on user profiles for members at this tier.
123123- supporter_badge: true
124124- quartermaster:
125125- # Storage quota limit (e.g. "5GB", "50GB", "1TB").
126126- quota: 100GB
127127- # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
128128- scan_on_push: true
129129- # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
130130- max_webhooks: -1
131131- # Allow all webhook trigger types. Free tiers only get scan:first.
132132- webhook_all_triggers: true
133133- # Show supporter badge on user profiles for members at this tier.
134134- supporter_badge: true
102102+ - # Tier name used as the key for crew assignments.
103103+ name: deckhand
104104+ # Storage quota limit (e.g. "5GB", "50GB", "1TB").
105105+ quota: 5GB
106106+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
107107+ scan_on_push: false
108108+ # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
109109+ max_webhooks: 1
110110+ # Allow all webhook trigger types. Free tiers only get scan:first.
111111+ webhook_all_triggers: false
112112+ # Show supporter badge on user profiles for members at this tier.
113113+ supporter_badge: true
114114+ - # Tier name used as the key for crew assignments.
115115+ name: bosun
116116+ # Storage quota limit (e.g. "5GB", "50GB", "1TB").
117117+ quota: 50GB
118118+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
119119+ scan_on_push: true
120120+ # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
121121+ max_webhooks: 5
122122+ # Allow all webhook trigger types. Free tiers only get scan:first.
123123+ webhook_all_triggers: true
124124+ # Show supporter badge on user profiles for members at this tier.
125125+ supporter_badge: true
126126+ - # Tier name used as the key for crew assignments.
127127+ name: quartermaster
128128+ # Storage quota limit (e.g. "5GB", "50GB", "1TB").
129129+ quota: 100GB
130130+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
131131+ scan_on_push: true
132132+ # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
133133+ max_webhooks: -1
134134+ # Allow all webhook trigger types. Free tiers only get scan:first.
135135+ webhook_all_triggers: true
136136+ # Show supporter badge on user profiles for members at this tier.
137137+ supporter_badge: true
135138 # Default tier assignment for new crew members.
136139 defaults:
137140 # Tier assigned to new crew members who don't have an explicit tier.
138141 new_crew_tier: deckhand
139142 # Show supporter badge on the hold owner's profile.
140140- owner_badge: false
143143+ owner_badge: true
141144# Vulnerability scanner settings. Empty disables scanning.
142145scanner:
143146 # Shared secret for scanner WebSocket auth. Empty disables scanning.
···48484949 atcr-hold:
5050 env_file:
5151- - ../atcr-secrets.env # Load S3/Storj credentials from external file
5151+ - ../atcr-secrets.env # Load S3/Storj credentials from external file
5252 # Base config: config-hold.example.yaml (passed via Air entrypoint)
5353 # Env vars below override config file values for local dev
5454 environment:
5555+ HOLD_SCANNER_SECRET: dev-secret
5556 HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080
5657 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
5758 HOLD_REGISTRATION_ALLOW_ALL_CREW: true
+39-10
pkg/appview/db/hold_store.go
···2626 AllowAllCrew bool `json:"allowAllCrew"`
2727 DeployedAt string `json:"deployedAt"`
2828 Region string `json:"region"`
2929- Successor string `json:"successor"` // DID of successor hold (migration redirect)
3030- SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]'
3131- UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
2929+ Successor string `json:"successor"` // DID of successor hold (migration redirect)
3030+ SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]'
3131+ UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
3232}
33333434// GetCaptainRecord retrieves a captain record from the cache
···135135 return false
136136}
137137138138+// normalizeDidWeb ensures did:web DIDs use %3A encoding for port separators.
139139+// This is a local copy to avoid importing atproto (prevents circular dependencies).
140140+func normalizeDidWeb(did string) string {
141141+ if !strings.HasPrefix(did, "did:web:") {
142142+ return did
143143+ }
144144+ host := strings.TrimPrefix(did, "did:web:")
145145+ if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
146146+ host = strings.Replace(host, ":", "%3A", 1)
147147+ }
148148+ return "did:web:" + host
149149+}
150150+138151// GetSupporterBadge returns the supporter badge tier name for a user on a specific hold.
139152// Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible,
140153// or the user isn't a member of the hold.
···143156 return ""
144157 }
145158159159+ // Normalize did:web encoding for consistent comparison
160160+ holdDID = normalizeDidWeb(holdDID)
161161+146162 captain, err := GetCaptainRecord(dbConn, holdDID)
147163 if err != nil || captain == nil || captain.SupporterBadgeTiers == "" {
148164 return ""
149165 }
150166151151- // Check if user is the captain (owner)
152152- if captain.OwnerDID == userDID {
153153- if captain.HasSupporterBadge("owner") {
154154- return "owner"
155155- }
156156- return ""
167167+ // If user is the owner and "owner" badge is enabled, show it
168168+ if captain.OwnerDID == userDID && captain.HasSupporterBadge("owner") {
169169+ return "owner"
157170 }
158171159172 // Look up crew membership for this user on this hold
···163176 }
164177165178 for _, m := range memberships {
166166- if m.HoldDID == holdDID && m.Tier != "" {
179179+ if normalizeDidWeb(m.HoldDID) == holdDID && m.Tier != "" {
167180 if captain.HasSupporterBadge(m.Tier) {
168181 return m.Tier
169182 }
···172185 }
173186174187 return ""
188188+}
189189+190190+// GetCrewHoldDID returns the hold DID from the user's most recent crew membership.
191191+// Used as a fallback when the user's DefaultHoldDID is not cached.
192192+func GetCrewHoldDID(db DBTX, memberDID string) string {
193193+ var holdDID string
194194+ err := db.QueryRow(`
195195+ SELECT hold_did FROM hold_crew_members
196196+ WHERE member_did = ?
197197+ ORDER BY updated_at DESC
198198+ LIMIT 1
199199+ `, memberDID).Scan(&holdDID)
200200+ if err != nil {
201201+ return ""
202202+ }
203203+ return holdDID
175204}
176205177206// ListHoldDIDs returns all known hold DIDs from the cache
+70-2
pkg/appview/handlers/settings.go
···2525 Region string `json:"region"`
2626 Membership string `json:"membership"`
2727 Permissions []string `json:"permissions,omitempty"`
2828+ Status string `json:"status"` // "" = unknown, "online", "offline"
2829}
29303031// SettingsHandler handles the settings page
···8283 if hold.Permissions != "" {
8384 if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil {
8485 slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", user.DID, "hold_did", hold.HoldDID, "error", err)
8686+ }
8787+ }
8888+8989+ // Check cached health status (non-blocking, nil = no data yet)
9090+ if h.HealthChecker != nil {
9191+ if status := h.HealthChecker.GetCachedStatus(hold.HoldDID); status != nil {
9292+ if status.Reachable {
9393+ display.Status = "online"
9494+ } else {
9595+ display.Status = "offline"
9696+ }
8597 }
8698 }
8799···220232 holdDID = r.FormValue("hold_endpoint")
221233 }
222234235235+ // Normalize did:web encoding (form URL-decoding can strip %3A → colon)
236236+ holdDID = atproto.NormalizeDID(holdDID)
237237+223238 // Validate hold DID if provided and database is available
224239 if holdDID != "" && h.DB != nil {
225240 // Check if user has access to this hold
···273288 if h.DB != nil {
274289 _ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID)
275290276276- // Refresh captain record for the selected hold so badge tiers are available immediately
291291+ // Ensure crew membership on the new hold (auto-registers on open holds)
292292+ // and refresh captain/crew cache so badge tiers are available immediately
277293 if holdDID != "" {
278278- go refreshCaptainRecord(holdDID, h.DB)
294294+ go func() {
295295+ storage.EnsureCrewMembership(
296296+ context.Background(), client, h.Refresher,
297297+ holdDID, middleware.GetGlobalAuthorizer(),
298298+ )
299299+ refreshCaptainRecord(holdDID, h.DB)
300300+ refreshCrewMembership(holdDID, user.DID, h.DB)
301301+ }()
279302 }
280303 }
281304···334357335358 slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers)
336359}
360360+361361+// refreshCrewMembership fetches a user's crew record from a hold and caches it locally.
362362+// Uses the deterministic rkey to do a direct O(1) lookup.
363363+func refreshCrewMembership(holdDID, userDID string, dbConn *sql.DB) {
364364+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
365365+ defer cancel()
366366+367367+ holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
368368+ if err != nil {
369369+ slog.Debug("Failed to resolve hold URL for crew refresh", "hold_did", holdDID, "error", err)
370370+ return
371371+ }
372372+373373+ rkey := atproto.CrewRecordKey(userDID)
374374+ holdClient := atproto.NewClient(holdURL, holdDID, "")
375375+ record, err := holdClient.GetRecord(ctx, atproto.CrewCollection, rkey)
376376+ if err != nil {
377377+ slog.Debug("No crew record found for user on hold", "hold_did", holdDID, "user_did", userDID, "error", err)
378378+ return
379379+ }
380380+381381+ var crewRecord atproto.CrewRecord
382382+ if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
383383+ slog.Debug("Failed to parse crew record for refresh", "hold_did", holdDID, "error", err)
384384+ return
385385+ }
386386+387387+ permJSON, _ := json.Marshal(crewRecord.Permissions)
388388+ member := &db.CrewMember{
389389+ HoldDID: holdDID,
390390+ MemberDID: crewRecord.Member,
391391+ Rkey: rkey,
392392+ Role: crewRecord.Role,
393393+ Permissions: string(permJSON),
394394+ Tier: crewRecord.Tier,
395395+ AddedAt: crewRecord.AddedAt,
396396+ }
397397+398398+ if err := db.UpsertCrewMember(dbConn, member); err != nil {
399399+ slog.Debug("Failed to cache crew membership on refresh", "hold_did", holdDID, "user_did", userDID, "error", err)
400400+ return
401401+ }
402402+403403+ slog.Info("Refreshed crew membership for user on hold", "hold_did", holdDID, "user_did", userDID, "tier", crewRecord.Tier)
404404+}
+9-2
pkg/appview/handlers/user.go
···64646565 // Check for supporter badge on user's default hold
6666 var supporterBadge string
6767- if hasProfile && h.ReadOnlyDB != nil && viewedUser.DefaultHoldDID != "" {
6868- supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, viewedUser.DefaultHoldDID)
6767+ if h.ReadOnlyDB != nil {
6868+ holdDID := viewedUser.DefaultHoldDID
6969+ if holdDID == "" {
7070+ // Fallback: check if user has any crew membership
7171+ holdDID = db.GetCrewHoldDID(h.ReadOnlyDB, viewedUser.DID)
7272+ }
7373+ if holdDID != "" {
7474+ supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, holdDID)
7575+ }
6976 }
70777178 // Build page meta
···155155 {{ if .OwnedHolds }}
156156 <optgroup label="Your Holds">
157157 {{ range .OwnedHolds }}
158158- <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
159159- {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
158158+ <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
159159+ {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
160160 </option>
161161 {{ end }}
162162 </optgroup>
···165165 {{ if .CrewHolds }}
166166 <optgroup label="Crew Member">
167167 {{ range .CrewHolds }}
168168- <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
169169- {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
168168+ <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
169169+ {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
170170 </option>
171171 {{ end }}
172172 </optgroup>
···175175 {{ if .EligibleHolds }}
176176 <optgroup label="Open Registration">
177177 {{ range .EligibleHolds }}
178178- <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
179179- {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
178178+ <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
179179+ {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
180180 </option>
181181 {{ end }}
182182 </optgroup>
···199199 <dd id="hold-did" class="font-mono"></dd>
200200 <dt class="text-base-content/70">Region:</dt>
201201 <dd id="hold-region"></dd>
202202+ <dt class="text-base-content/70">Status:</dt>
203203+ <dd id="hold-status-badge"></dd>
202204 <dt class="text-base-content/70">Your Access:</dt>
203205 <dd id="hold-access"></dd>
204206 </dl>
···407409408410 document.getElementById('hold-did').textContent = hold.did;
409411 document.getElementById('hold-region').textContent = hold.region || 'Unknown';
412412+413413+ // Set status badge
414414+ const statusEl = document.getElementById('hold-status-badge');
415415+ if (hold.status === 'offline') {
416416+ statusEl.innerHTML = '<span class="badge badge-sm badge-warning">Offline</span>';
417417+ } else if (hold.status === 'online') {
418418+ statusEl.innerHTML = '<span class="badge badge-sm badge-success">Online</span>';
419419+ } else {
420420+ statusEl.innerHTML = '<span class="text-base-content/60">Unknown</span>';
421421+ }
410422411423 // Set access level with badge
412424 const accessEl = document.getElementById('hold-access');
+1-1
pkg/appview/templates/partials/webhooks_list.html
···2828 <legend class="label"><span class="label-text">Trigger Events</span></legend>
2929 <div class="space-y-2 mt-1">
3030 {{ range .TriggerInfo }}
3131- <label class="flex items-start gap-3 cursor-pointer{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50{{ end }}">
3131+ <label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}">
3232 <input type="checkbox" name="trigger_{{ if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}"
3333 class="checkbox checkbox-sm mt-0.5"
3434 {{ if .AlwaysAvailable }}checked{{ end }}
+6-6
pkg/atproto/lexicon.go
···667667// Stored in the hold's embedded PDS to identify the hold owner and settings
668668// Uses CBOR encoding for efficient storage in hold's carstore
669669type CaptainRecord struct {
670670- Type string `json:"$type" cborgen:"$type"`
671671- Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
672672- Public bool `json:"public" cborgen:"public"` // Public read access
673673- AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
674674- EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
675675- DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
670670+ Type string `json:"$type" cborgen:"$type"`
671671+ Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
672672+ Public bool `json:"public" cborgen:"public"` // Public read access
673673+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
674674+ EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
675675+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
676676 Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
677677 Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
678678 SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles
+1
pkg/atproto/relays.go
···3333 {Name: "Hayes", URL: "https://relay.hayescmd.net"},
3434 {Name: "Xero", URL: "https://relay.xero.systems"},
3535 {Name: "Feeds Blue", URL: "https://relay.feeds.blue"},
3636+ {Name: "Waow", URL: "https://relay.waow.tech"},
3637}
37383839// RelayHTTPError indicates the relay responded with a non-200 status code.
+15
pkg/atproto/resolver.go
···118118 return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
119119}
120120121121+// NormalizeDID ensures did:web DIDs use %3A encoding for port separators
122122+// per the did:web spec. Other DID methods are returned as-is.
123123+// e.g., "did:web:172.28.0.3:8080" → "did:web:172.28.0.3%3A8080"
124124+func NormalizeDID(did string) string {
125125+ if !strings.HasPrefix(did, "did:web:") {
126126+ return did
127127+ }
128128+ host := strings.TrimPrefix(did, "did:web:")
129129+ // Only fix bare colons — skip if already percent-encoded
130130+ if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
131131+ host = strings.Replace(host, ":", "%3A", 1)
132132+ }
133133+ return "did:web:" + host
134134+}
135135+121136// didWebToURL converts a did:web DID to its base URL.
122137// did:web:example.com → https://example.com
123138// did:web:172.28.0.3%3A8080 → http://172.28.0.3:8080
···512512 pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
513513514514 // Add crew member with blob:write permission
515515- _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
515515+ _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
516516 if err != nil {
517517 t.Fatalf("Failed to add crew member: %v", err)
518518 }
···565565 pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
566566567567 // Add crew member with blob:read permission only (no blob:write)
568568- _, err := pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
568568+ _, err := pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "")
569569 if err != nil {
570570 t.Fatalf("Failed to add crew member: %v", err)
571571 }
···645645646646 // Add crew member with blob:write permission
647647 writerDID := "did:plc:writer123"
648648- _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
648648+ _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
649649 if err != nil {
650650 t.Fatalf("Failed to add crew member: %v", err)
651651 }
652652653653 // Add crew member without blob:write permission
654654 readerDID := "did:plc:reader123"
655655- _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
655655+ _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "")
656656 if err != nil {
657657 t.Fatalf("Failed to add crew member: %v", err)
658658 }
···796796797797 // Add crew member with ONLY blob:write permission (no blob:read)
798798 writerDID := "did:plc:writer123"
799799- _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
799799+ _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
800800 if err != nil {
801801 t.Fatalf("Failed to add crew writer: %v", err)
802802 }
···831831 // Also verify that crew with only blob:read still works
832832 t.Run("crew with blob:read can read", func(t *testing.T) {
833833 readerDID := "did:plc:reader123"
834834- _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
834834+ _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "")
835835 if err != nil {
836836 t.Fatalf("Failed to add crew reader: %v", err)
837837 }
···861861 // Verify crew with neither permission cannot read
862862 t.Run("crew without read or write cannot read", func(t *testing.T) {
863863 noPermDID := "did:plc:noperm123"
864864- _, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"})
864864+ _, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"}, "")
865865 if err != nil {
866866 t.Fatalf("Failed to add crew member: %v", err)
867867 }
···896896897897 // Add crew member with crew:admin permission
898898 adminDID := "did:plc:admin123"
899899- _, err := pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"})
899899+ _, err := pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"}, "")
900900 if err != nil {
901901 t.Fatalf("Failed to add crew admin: %v", err)
902902 }
903903904904 // Add crew member without crew:admin permission
905905 writerDID := "did:plc:writer123"
906906- _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
906906+ _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
907907 if err != nil {
908908 t.Fatalf("Failed to add crew writer: %v", err)
909909 }
···990990991991 // Add all crew members
992992 for _, tt := range tests {
993993- _, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions)
993993+ _, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions, "")
994994 if err != nil {
995995 t.Fatalf("Failed to add crew member %s: %v", tt.name, err)
996996 }
+2-1
pkg/hold/pds/crew.go
···1717// AddCrewMember adds a new crew member to the hold and commits to carstore
1818// Uses deterministic rkey based on member DID hash for O(1) lookups and automatic deduplication
1919// If the member already exists, updates their record (upsert behavior)
2020-func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
2020+func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string, tier string) (cid.Cid, error) {
2121 crewRecord := &atproto.CrewRecord{
2222 Type: atproto.CrewCollection,
2323 Member: memberDID,
2424 Role: role,
2525 Permissions: permissions,
2626+ Tier: tier,
2627 AddedAt: time.Now().Format(time.RFC3339),
2728 }
2829