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

Configure Feed

Select the types of activity you want to include in your feed.

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

+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 + }