A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

fix tier and supporter badge assignments. normalize did:web adresses with ports. various minor fixes

evan.jarrett.net 2b9ea997 356f9d52

verified
+446 -230
+38 -35
config-hold.example.yaml
··· 97 97 enabled: false 98 98 # Storage quota tiers. Empty disables quota enforcement. 99 99 quota: 100 - # Quota tiers keyed by rank name. Each tier has a human-readable quota limit. 100 + # Quota tiers ordered by rank (lowest to highest). Position determines rank. 101 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 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 135 138 # Default tier assignment for new crew members. 136 139 defaults: 137 140 # Tier assigned to new crew members who don't have an explicit tier. 138 141 new_crew_tier: deckhand 139 142 # Show supporter badge on the hold owner's profile. 140 - owner_badge: false 143 + owner_badge: true 141 144 # Vulnerability scanner settings. Empty disables scanning. 142 145 scanner: 143 146 # Shared secret for scanner WebSocket auth. Empty disables scanning.
+15 -15
deploy/upcloud/configs/hold.yaml.tmpl
··· 47 47 enabled: false 48 48 quota: 49 49 tiers: 50 - deckhand: 51 - quota: 5GB 52 - max_webhooks: 1 53 - bosun: 54 - quota: 50GB 55 - scan_on_push: true 56 - max_webhooks: 5 57 - webhook_all_triggers: true 58 - supporter_badge: true 59 - quartermaster: 60 - quota: 100GB 61 - scan_on_push: true 62 - max_webhooks: -1 63 - webhook_all_triggers: true 64 - supporter_badge: true 50 + - name: deckhand 51 + quota: 5GB 52 + max_webhooks: 1 53 + - name: bosun 54 + quota: 50GB 55 + scan_on_push: true 56 + max_webhooks: 5 57 + webhook_all_triggers: true 58 + supporter_badge: true 59 + - name: quartermaster 60 + quota: 100GB 61 + scan_on_push: true 62 + max_webhooks: -1 63 + webhook_all_triggers: true 64 + supporter_badge: true 65 65 defaults: 66 66 new_crew_tier: deckhand 67 67 owner_badge: true
+2 -1
docker-compose.yml
··· 48 48 49 49 atcr-hold: 50 50 env_file: 51 - - ../atcr-secrets.env # Load S3/Storj credentials from external file 51 + - ../atcr-secrets.env # Load S3/Storj credentials from external file 52 52 # Base config: config-hold.example.yaml (passed via Air entrypoint) 53 53 # Env vars below override config file values for local dev 54 54 environment: 55 + HOLD_SCANNER_SECRET: dev-secret 55 56 HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080 56 57 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg 57 58 HOLD_REGISTRATION_ALLOW_ALL_CREW: true
+39 -10
pkg/appview/db/hold_store.go
··· 26 26 AllowAllCrew bool `json:"allowAllCrew"` 27 27 DeployedAt string `json:"deployedAt"` 28 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 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 32 } 33 33 34 34 // GetCaptainRecord retrieves a captain record from the cache ··· 135 135 return false 136 136 } 137 137 138 + // 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 + 138 151 // GetSupporterBadge returns the supporter badge tier name for a user on a specific hold. 139 152 // Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible, 140 153 // or the user isn't a member of the hold. ··· 143 156 return "" 144 157 } 145 158 159 + // Normalize did:web encoding for consistent comparison 160 + holdDID = normalizeDidWeb(holdDID) 161 + 146 162 captain, err := GetCaptainRecord(dbConn, holdDID) 147 163 if err != nil || captain == nil || captain.SupporterBadgeTiers == "" { 148 164 return "" 149 165 } 150 166 151 - // Check if user is the captain (owner) 152 - if captain.OwnerDID == userDID { 153 - if captain.HasSupporterBadge("owner") { 154 - return "owner" 155 - } 156 - return "" 167 + // If user is the owner and "owner" badge is enabled, show it 168 + if captain.OwnerDID == userDID && captain.HasSupporterBadge("owner") { 169 + return "owner" 157 170 } 158 171 159 172 // Look up crew membership for this user on this hold ··· 163 176 } 164 177 165 178 for _, m := range memberships { 166 - if m.HoldDID == holdDID && m.Tier != "" { 179 + if normalizeDidWeb(m.HoldDID) == holdDID && m.Tier != "" { 167 180 if captain.HasSupporterBadge(m.Tier) { 168 181 return m.Tier 169 182 } ··· 172 185 } 173 186 174 187 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 175 204 } 176 205 177 206 // ListHoldDIDs returns all known hold DIDs from the cache
+70 -2
pkg/appview/handlers/settings.go
··· 25 25 Region string `json:"region"` 26 26 Membership string `json:"membership"` 27 27 Permissions []string `json:"permissions,omitempty"` 28 + Status string `json:"status"` // "" = unknown, "online", "offline" 28 29 } 29 30 30 31 // SettingsHandler handles the settings page ··· 82 83 if hold.Permissions != "" { 83 84 if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil { 84 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 + } 85 97 } 86 98 } 87 99 ··· 220 232 holdDID = r.FormValue("hold_endpoint") 221 233 } 222 234 235 + // Normalize did:web encoding (form URL-decoding can strip %3A → colon) 236 + holdDID = atproto.NormalizeDID(holdDID) 237 + 223 238 // Validate hold DID if provided and database is available 224 239 if holdDID != "" && h.DB != nil { 225 240 // Check if user has access to this hold ··· 273 288 if h.DB != nil { 274 289 _ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID) 275 290 276 - // Refresh captain record for the selected hold so badge tiers are available immediately 291 + // Ensure crew membership on the new hold (auto-registers on open holds) 292 + // and refresh captain/crew cache so badge tiers are available immediately 277 293 if holdDID != "" { 278 - go refreshCaptainRecord(holdDID, h.DB) 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 + }() 279 302 } 280 303 } 281 304 ··· 334 357 335 358 slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers) 336 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
··· 64 64 65 65 // Check for supporter badge on user's default hold 66 66 var supporterBadge string 67 - if hasProfile && h.ReadOnlyDB != nil && viewedUser.DefaultHoldDID != "" { 68 - supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, viewedUser.DefaultHoldDID) 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 + } 69 76 } 70 77 71 78 // Build page meta
-1
pkg/appview/handlers/webhooks.go
··· 402 402 "Message": message, 403 403 }) 404 404 } 405 -
+3 -3
pkg/appview/src/css/main.css
··· 410 410 @apply inline-flex items-stretch text-xs font-semibold leading-none; 411 411 } 412 412 .vuln-strip > span { 413 - @apply px-2 py-1 min-w-[1.75rem] text-center cursor-pointer; 413 + @apply px-2 py-1 min-w-7 text-center cursor-pointer; 414 414 } 415 - .vuln-strip > span:first-child { @apply rounded-l; } 416 - .vuln-strip > span:last-child { @apply rounded-r; } 415 + .vuln-strip > span:first-child { @apply rounded-l-sm; } 416 + .vuln-strip > span:last-child { @apply rounded-r-sm; } 417 417 .vuln-box-critical { background-color: oklch(45% 0.16 20); color: oklch(97% 0.01 20); } 418 418 .vuln-box-high { background-color: oklch(58% 0.18 35); color: oklch(97% 0.01 35); } 419 419 .vuln-box-medium { background-color: oklch(72% 0.15 70); color: oklch(25% 0.05 70); }
+18 -6
pkg/appview/templates/pages/settings.html
··· 155 155 {{ if .OwnedHolds }} 156 156 <optgroup label="Your Holds"> 157 157 {{ range .OwnedHolds }} 158 - <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}> 159 - {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }} 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 160 </option> 161 161 {{ end }} 162 162 </optgroup> ··· 165 165 {{ if .CrewHolds }} 166 166 <optgroup label="Crew Member"> 167 167 {{ range .CrewHolds }} 168 - <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}> 169 - {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }} 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 170 </option> 171 171 {{ end }} 172 172 </optgroup> ··· 175 175 {{ if .EligibleHolds }} 176 176 <optgroup label="Open Registration"> 177 177 {{ range .EligibleHolds }} 178 - <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}> 179 - {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }} 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 180 </option> 181 181 {{ end }} 182 182 </optgroup> ··· 199 199 <dd id="hold-did" class="font-mono"></dd> 200 200 <dt class="text-base-content/70">Region:</dt> 201 201 <dd id="hold-region"></dd> 202 + <dt class="text-base-content/70">Status:</dt> 203 + <dd id="hold-status-badge"></dd> 202 204 <dt class="text-base-content/70">Your Access:</dt> 203 205 <dd id="hold-access"></dd> 204 206 </dl> ··· 407 409 408 410 document.getElementById('hold-did').textContent = hold.did; 409 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 + } 410 422 411 423 // Set access level with badge 412 424 const accessEl = document.getElementById('hold-access');
+1 -1
pkg/appview/templates/partials/webhooks_list.html
··· 28 28 <legend class="label"><span class="label-text">Trigger Events</span></legend> 29 29 <div class="space-y-2 mt-1"> 30 30 {{ range .TriggerInfo }} 31 - <label class="flex items-start gap-3 cursor-pointer{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50{{ end }}"> 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 32 <input type="checkbox" name="trigger_{{ if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}" 33 33 class="checkbox checkbox-sm mt-0.5" 34 34 {{ if .AlwaysAvailable }}checked{{ end }}
+6 -6
pkg/atproto/lexicon.go
··· 667 667 // Stored in the hold's embedded PDS to identify the hold owner and settings 668 668 // Uses CBOR encoding for efficient storage in hold's carstore 669 669 type 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 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 676 Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional) 677 677 Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect) 678 678 SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles
+1
pkg/atproto/relays.go
··· 33 33 {Name: "Hayes", URL: "https://relay.hayescmd.net"}, 34 34 {Name: "Xero", URL: "https://relay.xero.systems"}, 35 35 {Name: "Feeds Blue", URL: "https://relay.feeds.blue"}, 36 + {Name: "Waow", URL: "https://relay.waow.tech"}, 36 37 } 37 38 38 39 // RelayHTTPError indicates the relay responded with a non-200 status code.
+15
pkg/atproto/resolver.go
··· 118 118 return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did) 119 119 } 120 120 121 + // 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 + 121 136 // didWebToURL converts a did:web DID to its base URL. 122 137 // did:web:example.com → https://example.com 123 138 // did:web:172.28.0.3%3A8080 → http://172.28.0.3:8080
+4 -4
pkg/auth/holdlocal/holdlocal_test.go
··· 199 199 holdPDS := createTestHoldPDS(t, ownerDID, false, false) 200 200 201 201 ctx := context.Background() 202 - _, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read", "blob:write"}) 202 + _, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read", "blob:write"}, "") 203 203 if err != nil { 204 204 t.Fatalf("Failed to add crew member: %v", err) 205 205 } ··· 224 224 holdPDS := createTestHoldPDS(t, ownerDID, false, false) 225 225 226 226 ctx := context.Background() 227 - _, err := holdPDS.AddCrewMember(ctx, "did:plc:bob456", "member", []string{"blob:read"}) 227 + _, err := holdPDS.AddCrewMember(ctx, "did:plc:bob456", "member", []string{"blob:read"}, "") 228 228 if err != nil { 229 229 t.Fatalf("Failed to add crew member: %v", err) 230 230 } ··· 325 325 holdPDS := createTestHoldPDS(t, ownerDID, false, true) 326 326 327 327 ctx := context.Background() 328 - _, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read", "blob:write"}) 328 + _, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read", "blob:write"}, "") 329 329 if err != nil { 330 330 t.Fatalf("Failed to add crew member: %v", err) 331 331 } ··· 350 350 holdPDS := createTestHoldPDS(t, ownerDID, false, false) 351 351 352 352 ctx := context.Background() 353 - _, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read"}) 353 + _, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read"}, "") 354 354 if err != nil { 355 355 t.Fatalf("Failed to add crew member: %v", err) 356 356 }
+9 -25
pkg/hold/admin/handlers_crew.go
··· 238 238 role = "member" 239 239 } 240 240 241 - // Add crew member 242 - _, err := ui.pds.AddCrewMember(ctx, did, role, permissions) 241 + // Resolve default tier from quota config if not specified 242 + if tier == "" && ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() { 243 + tier = ui.quotaMgr.GetDefaultTier() 244 + } 245 + 246 + // Add crew member with tier 247 + _, err := ui.pds.AddCrewMember(ctx, did, role, permissions, tier) 243 248 if err != nil { 244 249 slog.Error("Failed to add crew member", "did", did, "error", err) 245 250 setFlash(w, r, "error", "Failed to add crew member: "+err.Error()) 246 251 http.Redirect(w, r, "/admin/crew/add", http.StatusFound) 247 252 return 248 - } 249 - 250 - // Update tier if specified and different from default 251 - defaultTier := "default" 252 - if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() { 253 - defaultTier = ui.quotaMgr.GetDefaultTier() 254 - } 255 - 256 - if tier != "" && tier != defaultTier { 257 - if err := ui.pds.UpdateCrewMemberTier(ctx, did, tier); err != nil { 258 - slog.Warn("Failed to set tier for new crew member", "did", did, "tier", tier, "error", err) 259 - } 260 253 } 261 254 262 255 session := getSessionFromContext(ctx) ··· 362 355 return 363 356 } 364 357 365 - // Create new record with updated values 366 - if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions); err != nil { 358 + // Create new record with updated values (including tier) 359 + if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions, tier); err != nil { 367 360 setFlash(w, r, "error", "Failed to recreate crew record: "+err.Error()) 368 361 http.Redirect(w, r, "/admin#crew", http.StatusFound) 369 362 return 370 - } 371 - 372 - // Re-apply tier to new record 373 - if tier != "" { 374 - if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil { 375 - slog.Error("failed to update crew member tier", "error", err, "path", r.URL.Path) 376 - http.Error(w, "Failed to update tier", http.StatusInternalServerError) 377 - return 378 - } 379 363 } 380 364 } 381 365
+7 -9
pkg/hold/admin/handlers_crew_io.go
··· 155 155 role = "member" 156 156 } 157 157 158 - if _, err := ui.pds.AddCrewMember(ctx, entry.DID, role, entry.Permissions); err != nil { 158 + // Resolve tier: use entry tier if specified, otherwise default from quota config 159 + tier := entry.Tier 160 + if tier == "" && ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() { 161 + tier = ui.quotaMgr.GetDefaultTier() 162 + } 163 + 164 + if _, err := ui.pds.AddCrewMember(ctx, entry.DID, role, entry.Permissions, tier); err != nil { 159 165 result.Status = "error" 160 166 result.Reason = err.Error() 161 167 results = append(results, result) 162 168 continue 163 - } 164 - 165 - // Set tier if specified 166 - if entry.Tier != "" && ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() { 167 - if err := ui.pds.UpdateCrewMemberTier(ctx, entry.DID, entry.Tier); err != nil { 168 - slog.Warn("Failed to set tier for imported crew member", 169 - "did", entry.DID, "tier", entry.Tier, "error", err) 170 - } 171 169 } 172 170 173 171 result.Status = "added"
+4 -4
pkg/hold/config.go
··· 247 247 248 248 // Populate example quota tiers so operators see the structure 249 249 cfg.Quota = quota.Config{ 250 - Tiers: map[string]quota.TierConfig{ 251 - "deckhand": {Quota: "5GB", MaxWebhooks: 1}, 252 - "bosun": {Quota: "50GB", ScanOnPush: true, MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true}, 253 - "quartermaster": {Quota: "100GB", ScanOnPush: true, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true}, 250 + Tiers: []quota.TierConfig{ 251 + {Name: "deckhand", Quota: "5GB", MaxWebhooks: 1}, 252 + {Name: "bosun", Quota: "50GB", ScanOnPush: true, MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true}, 253 + {Name: "quartermaster", Quota: "100GB", ScanOnPush: true, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true}, 254 254 }, 255 255 Defaults: quota.DefaultsConfig{ 256 256 NewCrewTier: "deckhand",
+10 -10
pkg/hold/pds/auth_test.go
··· 512 512 pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false) 513 513 514 514 // Add crew member with blob:write permission 515 - _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}) 515 + _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "") 516 516 if err != nil { 517 517 t.Fatalf("Failed to add crew member: %v", err) 518 518 } ··· 565 565 pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false) 566 566 567 567 // Add crew member with blob:read permission only (no blob:write) 568 - _, err := pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}) 568 + _, err := pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "") 569 569 if err != nil { 570 570 t.Fatalf("Failed to add crew member: %v", err) 571 571 } ··· 645 645 646 646 // Add crew member with blob:write permission 647 647 writerDID := "did:plc:writer123" 648 - _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}) 648 + _, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "") 649 649 if err != nil { 650 650 t.Fatalf("Failed to add crew member: %v", err) 651 651 } 652 652 653 653 // Add crew member without blob:write permission 654 654 readerDID := "did:plc:reader123" 655 - _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}) 655 + _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "") 656 656 if err != nil { 657 657 t.Fatalf("Failed to add crew member: %v", err) 658 658 } ··· 796 796 797 797 // Add crew member with ONLY blob:write permission (no blob:read) 798 798 writerDID := "did:plc:writer123" 799 - _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}) 799 + _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "") 800 800 if err != nil { 801 801 t.Fatalf("Failed to add crew writer: %v", err) 802 802 } ··· 831 831 // Also verify that crew with only blob:read still works 832 832 t.Run("crew with blob:read can read", func(t *testing.T) { 833 833 readerDID := "did:plc:reader123" 834 - _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}) 834 + _, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "") 835 835 if err != nil { 836 836 t.Fatalf("Failed to add crew reader: %v", err) 837 837 } ··· 861 861 // Verify crew with neither permission cannot read 862 862 t.Run("crew without read or write cannot read", func(t *testing.T) { 863 863 noPermDID := "did:plc:noperm123" 864 - _, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"}) 864 + _, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"}, "") 865 865 if err != nil { 866 866 t.Fatalf("Failed to add crew member: %v", err) 867 867 } ··· 896 896 897 897 // Add crew member with crew:admin permission 898 898 adminDID := "did:plc:admin123" 899 - _, err := pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"}) 899 + _, err := pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"}, "") 900 900 if err != nil { 901 901 t.Fatalf("Failed to add crew admin: %v", err) 902 902 } 903 903 904 904 // Add crew member without crew:admin permission 905 905 writerDID := "did:plc:writer123" 906 - _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}) 906 + _, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "") 907 907 if err != nil { 908 908 t.Fatalf("Failed to add crew writer: %v", err) 909 909 } ··· 990 990 991 991 // Add all crew members 992 992 for _, tt := range tests { 993 - _, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions) 993 + _, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions, "") 994 994 if err != nil { 995 995 t.Fatalf("Failed to add crew member %s: %v", tt.name, err) 996 996 }
+2 -1
pkg/hold/pds/crew.go
··· 17 17 // AddCrewMember adds a new crew member to the hold and commits to carstore 18 18 // Uses deterministic rkey based on member DID hash for O(1) lookups and automatic deduplication 19 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) { 20 + func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string, tier string) (cid.Cid, error) { 21 21 crewRecord := &atproto.CrewRecord{ 22 22 Type: atproto.CrewCollection, 23 23 Member: memberDID, 24 24 Role: role, 25 25 Permissions: permissions, 26 + Tier: tier, 26 27 AddedAt: time.Now().Format(time.RFC3339), 27 28 } 28 29
+8 -8
pkg/hold/pds/crew_test.go
··· 18 18 role := "writer" 19 19 permissions := []string{"blob:read", "blob:write"} 20 20 21 - recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions) 21 + recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "") 22 22 if err != nil { 23 23 t.Fatalf("AddCrewMember failed: %v", err) 24 24 } ··· 71 71 role := "reader" 72 72 permissions := []string{"blob:read"} 73 73 74 - _, err := pds.AddCrewMember(ctx, memberDID, role, permissions) 74 + _, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "") 75 75 if err != nil { 76 76 t.Fatalf("AddCrewMember failed: %v", err) 77 77 } ··· 174 174 } 175 175 176 176 for _, m := range members { 177 - _, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions) 177 + _, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions, "") 178 178 if err != nil { 179 179 t.Fatalf("AddCrewMember failed for %s: %v", m.did, err) 180 180 } ··· 230 230 231 231 // Add crew member 232 232 memberDID := "did:plc:alice123" 233 - _, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read", "blob:write"}) 233 + _, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read", "blob:write"}, "") 234 234 if err != nil { 235 235 t.Fatalf("AddCrewMember failed: %v", err) 236 236 } ··· 301 301 } 302 302 303 303 for _, did := range dids { 304 - _, err := pds.AddCrewMember(ctx, did, "writer", []string{"blob:read"}) 304 + _, err := pds.AddCrewMember(ctx, did, "writer", []string{"blob:read"}, "") 305 305 if err != nil { 306 306 t.Fatalf("AddCrewMember failed for %s: %v", did, err) 307 307 } ··· 447 447 448 448 // Add crew member 449 449 memberDID := "did:plc:alice123" 450 - _, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read"}) 450 + _, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read"}, "") 451 451 if err != nil { 452 452 t.Fatalf("AddCrewMember failed: %v", err) 453 453 } ··· 489 489 role := "writer" 490 490 permissions := []string{"blob:read", "blob:write"} 491 491 492 - recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions) 492 + recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "") 493 493 if err != nil { 494 494 t.Fatalf("AddCrewMember failed with did:web: %v", err) 495 495 } ··· 553 553 } 554 554 555 555 for _, m := range members { 556 - _, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions) 556 + _, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions, "") 557 557 if err != nil { 558 558 t.Fatalf("AddCrewMember failed for %s: %v", m.did, err) 559 559 }
+7 -7
pkg/hold/pds/layer_test.go
··· 355 355 configPath := filepath.Join(tmpDir, "quotas.yaml") 356 356 configContent := ` 357 357 tiers: 358 - deckhand: 358 + - name: deckhand 359 359 quota: 5GB 360 - bosun: 360 + - name: bosun 361 361 quota: 50GB 362 362 363 363 defaults: ··· 428 428 configPath := filepath.Join(tmpDir, "quotas.yaml") 429 429 configContent := ` 430 430 tiers: 431 - deckhand: 431 + - name: deckhand 432 432 quota: 5GB 433 - bosun: 433 + - name: bosun 434 434 quota: 50GB 435 435 436 436 defaults: ··· 502 502 configPath := filepath.Join(tmpDir, "quotas.yaml") 503 503 configContent := ` 504 504 tiers: 505 - deckhand: 505 + - name: deckhand 506 506 quota: 5GB 507 - bosun: 507 + - name: bosun 508 508 quota: 50GB 509 509 510 510 defaults: ··· 656 656 configPath := filepath.Join(tmpDir, "quotas.yaml") 657 657 configContent := ` 658 658 tiers: 659 - deckhand: 659 + - name: deckhand 660 660 quota: 5GB 661 661 662 662 defaults:
+1 -1
pkg/hold/pds/server.go
··· 288 288 "region", cfg.Region) 289 289 290 290 // 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"}) 291 + _, err = p.AddCrewMember(ctx, cfg.OwnerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}, "") 292 292 if err != nil { 293 293 return fmt.Errorf("failed to add owner as crew member: %w", err) 294 294 }
+2 -2
pkg/hold/pds/server_test.go
··· 421 421 422 422 // Add did:web crew member 423 423 webMember := "did:web:bob.example.com" 424 - _, err = pds.AddCrewMember(ctx, webMember, "writer", []string{"blob:read", "blob:write"}) 424 + _, err = pds.AddCrewMember(ctx, webMember, "writer", []string{"blob:read", "blob:write"}, "") 425 425 if err != nil { 426 426 t.Fatalf("AddCrewMember failed with did:web: %v", err) 427 427 } ··· 488 488 489 489 // Create crew member WITHOUT captain (unusual state) 490 490 ownerDID := "did:plc:alice123" 491 - _, err = pds.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}) 491 + _, err = pds.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}, "") 492 492 if err != nil { 493 493 t.Fatalf("AddCrewMember failed: %v", err) 494 494 }
+42 -19
pkg/hold/pds/webhooks.go
··· 38 38 39 39 // WebhookPayload is the JSON body sent to webhook URLs 40 40 type WebhookPayload struct { 41 - Trigger string `json:"trigger"` 42 - HoldDID string `json:"holdDid"` 43 - HoldEndpoint string `json:"holdEndpoint"` 44 - Manifest WebhookManifestInfo `json:"manifest"` 45 - Scan WebhookScanInfo `json:"scan"` 46 - Previous *WebhookVulnCounts `json:"previous"` 41 + Trigger string `json:"trigger"` 42 + HoldDID string `json:"holdDid"` 43 + HoldEndpoint string `json:"holdEndpoint"` 44 + Manifest WebhookManifestInfo `json:"manifest"` 45 + Scan WebhookScanInfo `json:"scan"` 46 + Previous *WebhookVulnCounts `json:"previous"` 47 47 } 48 48 49 49 // WebhookManifestInfo describes the scanned manifest ··· 56 56 57 57 // WebhookScanInfo describes the scan results 58 58 type WebhookScanInfo struct { 59 - ScannedAt string `json:"scannedAt"` 60 - ScannerVersion string `json:"scannerVersion"` 59 + ScannedAt string `json:"scannedAt"` 60 + ScannerVersion string `json:"scannerVersion"` 61 61 Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"` 62 62 } 63 63 ··· 387 387 return masked 388 388 } 389 389 390 + // isCaptain checks if the given DID is the hold captain (owner) 391 + func (h *XRPCHandler) isCaptain(ctx context.Context, did string) bool { 392 + _, captain, err := h.pds.GetCaptainRecord(ctx) 393 + if err != nil { 394 + slog.Debug("isCaptain: failed to get captain record", "error", err) 395 + return false 396 + } 397 + if captain == nil { 398 + slog.Debug("isCaptain: captain record is nil") 399 + return false 400 + } 401 + match := captain.Owner == did 402 + if !match { 403 + slog.Debug("isCaptain: DID mismatch", "captain.Owner", captain.Owner, "user.DID", did) 404 + } 405 + return match 406 + } 407 + 390 408 // ---- XRPC Handlers ---- 391 409 392 410 // HandleListWebhooks returns webhook configs for a user ··· 411 429 return 412 430 } 413 431 414 - // Get tier limits 432 + // Get tier limits — captains get unlimited access 415 433 maxWebhooks, allTriggers := 1, false 416 - if h.quotaMgr != nil { 434 + if h.isCaptain(r.Context(), user.DID) { 435 + maxWebhooks, allTriggers = -1, true 436 + } else if h.quotaMgr != nil { 417 437 _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID) 418 438 tierKey := "" 419 439 if crew != nil { ··· 461 481 return 462 482 } 463 483 464 - // Tier enforcement 465 - tierKey := "" 466 - _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID) 467 - if crew != nil { 468 - tierKey = crew.Tier 469 - } 470 - 484 + // Tier enforcement — captains get unlimited access 471 485 maxWebhooks, allTriggers := 1, false 472 - if h.quotaMgr != nil { 473 - maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey) 486 + if h.isCaptain(r.Context(), user.DID) { 487 + maxWebhooks, allTriggers = -1, true 488 + } else { 489 + tierKey := "" 490 + _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID) 491 + if crew != nil { 492 + tierKey = crew.Tier 493 + } 494 + if h.quotaMgr != nil { 495 + maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey) 496 + } 474 497 } 475 498 476 499 // Check webhook count limit
+8 -3
pkg/hold/pds/xrpc.go
··· 1439 1439 } 1440 1440 } 1441 1441 1442 - // Create new crew record 1442 + // Create new crew record with default tier from quota config 1443 + defaultTier := "" 1444 + if h.quotaMgr != nil && h.quotaMgr.IsEnabled() { 1445 + defaultTier = h.quotaMgr.GetDefaultTier() 1446 + } 1443 1447 slog.Debug("Creating new crew record", 1444 1448 "did", user.DID, 1445 1449 "role", req.Role, 1446 - "permissions", req.Permissions) 1447 - recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions) 1450 + "permissions", req.Permissions, 1451 + "tier", defaultTier) 1452 + recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions, defaultTier) 1448 1453 if err != nil { 1449 1454 slog.Error("Failed to create crew record", 1450 1455 "error", err,
+9 -9
pkg/hold/pds/xrpc_test.go
··· 441 441 // Verify we can also get crew records 442 442 // Add a crew member first 443 443 memberDID := "did:plc:testmember" 444 - _, err := handler.pds.AddCrewMember(ctx, memberDID, "reader", []string{"blob:read"}) 444 + _, err := handler.pds.AddCrewMember(ctx, memberDID, "reader", []string{"blob:read"}, "") 445 445 if err != nil { 446 446 t.Fatalf("Failed to add crew member: %v", err) 447 447 } ··· 569 569 } 570 570 571 571 for _, did := range memberDIDs { 572 - _, err := handler.pds.AddCrewMember(ctx, did, "reader", []string{"blob:read"}) 572 + _, err := handler.pds.AddCrewMember(ctx, did, "reader", []string{"blob:read"}, "") 573 573 if err != nil { 574 574 t.Fatalf("Failed to add crew member %s: %v", did, err) 575 575 } ··· 629 629 // Note: Bootstrap already added 1 crew member 630 630 // Add 4 more for a total of 5 631 631 for i := range 4 { 632 - _, err := handler.pds.AddCrewMember(ctx, "did:plc:member"+string(rune(i+'0')), "reader", []string{"blob:read"}) 632 + _, err := handler.pds.AddCrewMember(ctx, "did:plc:member"+string(rune(i+'0')), "reader", []string{"blob:read"}, "") 633 633 if err != nil { 634 634 t.Fatalf("Failed to add crew member: %v", err) 635 635 } ··· 693 693 694 694 // Add crew members 695 695 for i := range 3 { 696 - _, err := handler.pds.AddCrewMember(ctx, "did:plc:member"+string(rune(i+'0')), "reader", []string{"blob:read"}) 696 + _, err := handler.pds.AddCrewMember(ctx, "did:plc:member"+string(rune(i+'0')), "reader", []string{"blob:read"}, "") 697 697 if err != nil { 698 698 t.Fatalf("Failed to add crew member: %v", err) 699 699 } ··· 850 850 } 851 851 852 852 for _, did := range memberDIDs { 853 - _, err := handler.pds.AddCrewMember(ctx, did, "reader", []string{"blob:read"}) 853 + _, err := handler.pds.AddCrewMember(ctx, did, "reader", []string{"blob:read"}, "") 854 854 if err != nil { 855 855 t.Fatalf("Failed to add crew member %s: %v", did, err) 856 856 } ··· 908 908 909 909 // Add 4 more crew members for total of 5 910 910 for i := range 4 { 911 - _, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"}) 911 + _, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"}, "") 912 912 if err != nil { 913 913 t.Fatalf("Failed to add crew member: %v", err) 914 914 } ··· 988 988 989 989 // Add crew members 990 990 for i := range 3 { 991 - _, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"}) 991 + _, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"}, "") 992 992 if err != nil { 993 993 t.Fatalf("Failed to add crew member: %v", err) 994 994 } ··· 1072 1072 1073 1073 // Add a crew member to delete 1074 1074 memberDID := "did:plc:testmember" 1075 - _, err := handler.pds.AddCrewMember(ctx, memberDID, "reader", []string{"blob:read"}) 1075 + _, err := handler.pds.AddCrewMember(ctx, memberDID, "reader", []string{"blob:read"}, "") 1076 1076 if err != nil { 1077 1077 t.Fatalf("Failed to add crew member: %v", err) 1078 1078 } ··· 1843 1843 1844 1844 // Pre-add the user as a crew member 1845 1845 testUserDID := "did:plc:existinguser123" 1846 - _, err = handler.pds.AddCrewMember(ctx, testUserDID, "member", []string{"blob:read", "blob:write"}) 1846 + _, err = handler.pds.AddCrewMember(ctx, testUserDID, "member", []string{"blob:read", "blob:write"}, "") 1847 1847 if err != nil { 1848 1848 t.Fatalf("Failed to pre-add crew member: %v", err) 1849 1849 }
+42 -24
pkg/hold/quota/config.go
··· 5 5 "fmt" 6 6 "os" 7 7 "regexp" 8 - "sort" 9 8 "strconv" 10 9 "strings" 11 10 ··· 14 13 15 14 // Config represents quota tier configuration. 16 15 type Config struct { 17 - // Quota tiers keyed by name (e.g. "deckhand", "bosun", "quartermaster"). 18 - Tiers map[string]TierConfig `yaml:"tiers" comment:"Quota tiers keyed by rank name. Each tier has a human-readable quota limit."` 16 + // Quota tiers ordered by rank (lowest to highest). Position determines rank. 17 + Tiers []TierConfig `yaml:"tiers" comment:"Quota tiers ordered by rank (lowest to highest). Position determines rank."` 19 18 20 19 // Default tier settings. 21 20 Defaults DefaultsConfig `yaml:"defaults" comment:"Default tier assignment for new crew members."` 22 21 } 23 22 23 + // TierByName returns the TierConfig for the given name, or nil if not found. 24 + func (c *Config) TierByName(name string) *TierConfig { 25 + for i := range c.Tiers { 26 + if c.Tiers[i].Name == name { 27 + return &c.Tiers[i] 28 + } 29 + } 30 + return nil 31 + } 32 + 24 33 // TierConfig represents a single tier's configuration. 25 34 type TierConfig struct { 35 + // Tier name (e.g. "deckhand", "bosun", "quartermaster"). 36 + Name string `yaml:"name" comment:"Tier name used as the key for crew assignments."` 37 + 26 38 // Human-readable size limit, e.g. "5GB", "50GB", "1TB". 27 39 Quota string `yaml:"quota" comment:"Storage quota limit (e.g. \"5GB\", \"50GB\", \"1TB\")."` 28 40 ··· 78 90 m.config = &cfg 79 91 80 92 // Parse and resolve all tiers 81 - for name, tier := range cfg.Tiers { 93 + for _, tier := range cfg.Tiers { 82 94 bytes, err := ParseHumanBytes(tier.Quota) 83 95 if err != nil { 84 - return nil, fmt.Errorf("invalid quota for tier %q: %w", name, err) 96 + return nil, fmt.Errorf("invalid quota for tier %q: %w", tier.Name, err) 85 97 } 86 - m.tiers[name] = bytes 98 + m.tiers[tier.Name] = bytes 87 99 } 88 100 89 101 return m, nil ··· 102 114 103 115 m.config = cfg 104 116 105 - for name, tier := range cfg.Tiers { 117 + for _, tier := range cfg.Tiers { 106 118 bytes, err := ParseHumanBytes(tier.Quota) 107 119 if err != nil { 108 - return nil, fmt.Errorf("invalid quota for tier %q: %w", name, err) 120 + return nil, fmt.Errorf("invalid quota for tier %q: %w", tier.Name, err) 109 121 } 110 - m.tiers[name] = bytes 122 + m.tiers[tier.Name] = bytes 111 123 } 112 124 113 125 return m, nil ··· 193 205 } 194 206 195 207 if tierKey != "" { 196 - if tier, ok := m.config.Tiers[tierKey]; ok { 208 + if tier := m.config.TierByName(tierKey); tier != nil { 197 209 return tier.ScanOnPush 198 210 } 199 211 } 200 212 201 213 // Fall back to default tier 202 214 if m.config.Defaults.NewCrewTier != "" { 203 - if tier, ok := m.config.Tiers[m.config.Defaults.NewCrewTier]; ok { 215 + if tier := m.config.TierByName(m.config.Defaults.NewCrewTier); tier != nil { 204 216 return tier.ScanOnPush 205 217 } 206 218 } ··· 217 229 } 218 230 219 231 if tierKey != "" { 220 - if tier, ok := m.config.Tiers[tierKey]; ok { 232 + if tier := m.config.TierByName(tierKey); tier != nil { 221 233 max := tier.MaxWebhooks 222 234 if max == 0 { 223 235 max = 1 // default ··· 228 240 229 241 // Fall back to default tier 230 242 if m.config.Defaults.NewCrewTier != "" { 231 - if tier, ok := m.config.Tiers[m.config.Defaults.NewCrewTier]; ok { 243 + if tier := m.config.TierByName(m.config.Defaults.NewCrewTier); tier != nil { 232 244 max := tier.MaxWebhooks 233 245 if max == 0 { 234 246 max = 1 ··· 240 252 return 1, false 241 253 } 242 254 243 - // BadgeTiers returns the names of tiers that have supporter badges enabled. 244 - // Includes "owner" if defaults.owner_badge is true. 255 + // BadgeTiers returns the names of tiers that have supporter badges enabled, 256 + // ordered from highest rank to lowest. Includes "owner" first if 257 + // defaults.owner_badge is true. 245 258 // Returns nil if quotas are disabled or no tiers have badges. 246 259 func (m *Manager) BadgeTiers() []string { 247 260 if !m.IsEnabled() { ··· 251 264 if m.config.Defaults.OwnerBadge { 252 265 tiers = append(tiers, "owner") 253 266 } 254 - for name, tier := range m.config.Tiers { 255 - if tier.SupporterBadge { 256 - tiers = append(tiers, name) 267 + // Iterate in reverse: highest rank first 268 + for i := len(m.config.Tiers) - 1; i >= 0; i-- { 269 + if m.config.Tiers[i].SupporterBadge { 270 + tiers = append(tiers, m.config.Tiers[i].Name) 257 271 } 258 272 } 259 - sort.Strings(tiers) 260 273 return tiers 261 274 } 262 275 ··· 271 284 Limit *int64 272 285 } 273 286 274 - // ListTiers returns all configured tiers with their limits 287 + // ListTiers returns all configured tiers with their limits, in rank order 288 + // (lowest to highest). 275 289 func (m *Manager) ListTiers() []TierInfo { 276 290 if !m.IsEnabled() { 277 291 return nil 278 292 } 279 293 280 - tiers := make([]TierInfo, 0, len(m.tiers)) 281 - for key, limit := range m.tiers { 282 - limitCopy := limit // Create copy to take address of 294 + tiers := make([]TierInfo, 0, len(m.config.Tiers)) 295 + for _, tier := range m.config.Tiers { 296 + limit, ok := m.tiers[tier.Name] 297 + if !ok { 298 + continue 299 + } 300 + limitCopy := limit 283 301 tiers = append(tiers, TierInfo{ 284 - Key: key, 302 + Key: tier.Name, 285 303 Limit: &limitCopy, 286 304 }) 287 305 }
+74 -22
pkg/hold/quota/config_test.go
··· 111 111 112 112 func TestNewManagerFromConfig_WithTiers(t *testing.T) { 113 113 cfg := &Config{ 114 - Tiers: map[string]TierConfig{ 115 - "deckhand": {Quota: "5GB"}, 116 - "bosun": {Quota: "50GB"}, 117 - "quartermaster": {Quota: "100GB"}, 114 + Tiers: []TierConfig{ 115 + {Name: "deckhand", Quota: "5GB"}, 116 + {Name: "bosun", Quota: "50GB"}, 117 + {Name: "quartermaster", Quota: "100GB"}, 118 118 }, 119 119 Defaults: DefaultsConfig{ 120 120 NewCrewTier: "deckhand", ··· 165 165 166 166 configContent := ` 167 167 tiers: 168 - deckhand: 168 + - name: deckhand 169 169 quota: 5GB 170 - bosun: 170 + - name: bosun 171 171 quota: 50GB 172 - quartermaster: 172 + - name: quartermaster 173 173 quota: 100GB 174 174 175 175 defaults: ··· 225 225 226 226 configContent := ` 227 227 tiers: 228 - deckhand: 228 + - name: deckhand 229 229 quota: 5GB 230 - quartermaster: 230 + - name: quartermaster 231 231 quota: 50GB 232 232 233 233 defaults: ··· 278 278 279 279 configContent := ` 280 280 tiers: 281 - deckhand: 281 + - name: deckhand 282 282 quota: invalid_size 283 283 284 284 defaults: ··· 307 307 308 308 func TestScanOnPush_ExplicitTier(t *testing.T) { 309 309 cfg := &Config{ 310 - Tiers: map[string]TierConfig{ 311 - "deckhand": {Quota: "5GB", ScanOnPush: false}, 312 - "bosun": {Quota: "50GB", ScanOnPush: true}, 313 - "quartermaster": {Quota: "100GB", ScanOnPush: true}, 310 + Tiers: []TierConfig{ 311 + {Name: "deckhand", Quota: "5GB", ScanOnPush: false}, 312 + {Name: "bosun", Quota: "50GB", ScanOnPush: true}, 313 + {Name: "quartermaster", Quota: "100GB", ScanOnPush: true}, 314 314 }, 315 315 Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, 316 316 } ··· 332 332 333 333 func TestScanOnPush_FallbackToDefault(t *testing.T) { 334 334 cfg := &Config{ 335 - Tiers: map[string]TierConfig{ 336 - "deckhand": {Quota: "5GB", ScanOnPush: false}, 337 - "bosun": {Quota: "50GB", ScanOnPush: true}, 335 + Tiers: []TierConfig{ 336 + {Name: "deckhand", Quota: "5GB", ScanOnPush: false}, 337 + {Name: "bosun", Quota: "50GB", ScanOnPush: true}, 338 338 }, 339 339 Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, 340 340 } ··· 356 356 357 357 func TestScanOnPush_FallbackToDefaultTrue(t *testing.T) { 358 358 cfg := &Config{ 359 - Tiers: map[string]TierConfig{ 360 - "deckhand": {Quota: "5GB", ScanOnPush: true}, 359 + Tiers: []TierConfig{ 360 + {Name: "deckhand", Quota: "5GB", ScanOnPush: true}, 361 361 }, 362 362 Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, 363 363 } ··· 375 375 func TestScanOnPush_ZeroValue(t *testing.T) { 376 376 // When scan_on_push is omitted from config, Go zero value = false 377 377 cfg := &Config{ 378 - Tiers: map[string]TierConfig{ 379 - "deckhand": {Quota: "5GB"}, // ScanOnPush not set 378 + Tiers: []TierConfig{ 379 + {Name: "deckhand", Quota: "5GB"}, // ScanOnPush not set 380 380 }, 381 381 Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, 382 382 } ··· 396 396 397 397 configContent := ` 398 398 tiers: 399 - quartermaster: 399 + - name: quartermaster 400 400 quota: 50GB 401 401 402 402 defaults: ··· 426 426 t.Errorf("expected 50GB limit for quartermaster, got %d", *limit) 427 427 } 428 428 } 429 + 430 + func TestBadgeTiers_RankOrder(t *testing.T) { 431 + cfg := &Config{ 432 + Tiers: []TierConfig{ 433 + {Name: "deckhand", Quota: "5GB"}, 434 + {Name: "bosun", Quota: "50GB", SupporterBadge: true}, 435 + {Name: "quartermaster", Quota: "100GB", SupporterBadge: true}, 436 + }, 437 + Defaults: DefaultsConfig{OwnerBadge: true}, 438 + } 439 + m, err := NewManagerFromConfig(cfg) 440 + if err != nil { 441 + t.Fatal(err) 442 + } 443 + 444 + tiers := m.BadgeTiers() 445 + // Expected: owner first, then highest rank first 446 + expected := []string{"owner", "quartermaster", "bosun"} 447 + if len(tiers) != len(expected) { 448 + t.Fatalf("got %v, want %v", tiers, expected) 449 + } 450 + for i := range expected { 451 + if tiers[i] != expected[i] { 452 + t.Errorf("tiers[%d] = %q, want %q", i, tiers[i], expected[i]) 453 + } 454 + } 455 + } 456 + 457 + func TestListTiers_PreservesOrder(t *testing.T) { 458 + cfg := &Config{ 459 + Tiers: []TierConfig{ 460 + {Name: "deckhand", Quota: "5GB"}, 461 + {Name: "bosun", Quota: "50GB"}, 462 + {Name: "quartermaster", Quota: "100GB"}, 463 + }, 464 + } 465 + m, err := NewManagerFromConfig(cfg) 466 + if err != nil { 467 + t.Fatal(err) 468 + } 469 + 470 + tiers := m.ListTiers() 471 + if len(tiers) != 3 { 472 + t.Fatalf("expected 3 tiers, got %d", len(tiers)) 473 + } 474 + expected := []string{"deckhand", "bosun", "quartermaster"} 475 + for i, name := range expected { 476 + if tiers[i].Key != name { 477 + t.Errorf("tiers[%d].Key = %q, want %q", i, tiers[i].Key, name) 478 + } 479 + } 480 + }