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

This commit is contained in:
Evan Jarrett
2026-02-22 11:16:55 -06:00
parent 356f9d529a
commit 2b9ea997ac
28 changed files with 446 additions and 230 deletions
+38 -35
View File
@@ -97,47 +97,50 @@ gc:
enabled: false
# Storage quota tiers. Empty disables quota enforcement.
quota:
# Quota tiers keyed by rank name. Each tier has a human-readable quota limit.
# Quota tiers ordered by rank (lowest to highest). Position determines rank.
tiers:
bosun:
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
quota: 50GB
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
scan_on_push: true
# Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
max_webhooks: 5
# Allow all webhook trigger types. Free tiers only get scan:first.
webhook_all_triggers: true
# Show supporter badge on user profiles for members at this tier.
supporter_badge: true
deckhand:
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
quota: 5GB
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
scan_on_push: false
# Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
max_webhooks: 1
# Allow all webhook trigger types. Free tiers only get scan:first.
webhook_all_triggers: false
# Show supporter badge on user profiles for members at this tier.
supporter_badge: true
quartermaster:
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
quota: 100GB
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
scan_on_push: true
# Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
max_webhooks: -1
# Allow all webhook trigger types. Free tiers only get scan:first.
webhook_all_triggers: true
# Show supporter badge on user profiles for members at this tier.
supporter_badge: true
- # Tier name used as the key for crew assignments.
name: deckhand
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
quota: 5GB
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
scan_on_push: false
# Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
max_webhooks: 1
# Allow all webhook trigger types. Free tiers only get scan:first.
webhook_all_triggers: false
# Show supporter badge on user profiles for members at this tier.
supporter_badge: true
- # Tier name used as the key for crew assignments.
name: bosun
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
quota: 50GB
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
scan_on_push: true
# Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
max_webhooks: 5
# Allow all webhook trigger types. Free tiers only get scan:first.
webhook_all_triggers: true
# Show supporter badge on user profiles for members at this tier.
supporter_badge: true
- # Tier name used as the key for crew assignments.
name: quartermaster
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
quota: 100GB
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
scan_on_push: true
# Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
max_webhooks: -1
# Allow all webhook trigger types. Free tiers only get scan:first.
webhook_all_triggers: true
# Show supporter badge on user profiles for members at this tier.
supporter_badge: true
# Default tier assignment for new crew members.
defaults:
# Tier assigned to new crew members who don't have an explicit tier.
new_crew_tier: deckhand
# Show supporter badge on the hold owner's profile.
owner_badge: false
owner_badge: true
# Vulnerability scanner settings. Empty disables scanning.
scanner:
# Shared secret for scanner WebSocket auth. Empty disables scanning.
+15 -15
View File
@@ -47,21 +47,21 @@ gc:
enabled: false
quota:
tiers:
deckhand:
quota: 5GB
max_webhooks: 1
bosun:
quota: 50GB
scan_on_push: true
max_webhooks: 5
webhook_all_triggers: true
supporter_badge: true
quartermaster:
quota: 100GB
scan_on_push: true
max_webhooks: -1
webhook_all_triggers: true
supporter_badge: true
- name: deckhand
quota: 5GB
max_webhooks: 1
- name: bosun
quota: 50GB
scan_on_push: true
max_webhooks: 5
webhook_all_triggers: true
supporter_badge: true
- name: quartermaster
quota: 100GB
scan_on_push: true
max_webhooks: -1
webhook_all_triggers: true
supporter_badge: true
defaults:
new_crew_tier: deckhand
owner_badge: true
+2 -1
View File
@@ -48,10 +48,11 @@ services:
atcr-hold:
env_file:
- ../atcr-secrets.env # Load S3/Storj credentials from external file
- ../atcr-secrets.env # Load S3/Storj credentials from external file
# Base config: config-hold.example.yaml (passed via Air entrypoint)
# Env vars below override config file values for local dev
environment:
HOLD_SCANNER_SECRET: dev-secret
HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080
HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
HOLD_REGISTRATION_ALLOW_ALL_CREW: true
+39 -10
View File
@@ -26,9 +26,9 @@ type HoldCaptainRecord struct {
AllowAllCrew bool `json:"allowAllCrew"`
DeployedAt string `json:"deployedAt"`
Region string `json:"region"`
Successor string `json:"successor"` // DID of successor hold (migration redirect)
SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]'
UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
Successor string `json:"successor"` // DID of successor hold (migration redirect)
SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]'
UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
}
// GetCaptainRecord retrieves a captain record from the cache
@@ -135,6 +135,19 @@ func (r *HoldCaptainRecord) HasSupporterBadge(tier string) bool {
return false
}
// normalizeDidWeb ensures did:web DIDs use %3A encoding for port separators.
// This is a local copy to avoid importing atproto (prevents circular dependencies).
func normalizeDidWeb(did string) string {
if !strings.HasPrefix(did, "did:web:") {
return did
}
host := strings.TrimPrefix(did, "did:web:")
if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
host = strings.Replace(host, ":", "%3A", 1)
}
return "did:web:" + host
}
// GetSupporterBadge returns the supporter badge tier name for a user on a specific hold.
// Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible,
// or the user isn't a member of the hold.
@@ -143,17 +156,17 @@ func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string {
return ""
}
// Normalize did:web encoding for consistent comparison
holdDID = normalizeDidWeb(holdDID)
captain, err := GetCaptainRecord(dbConn, holdDID)
if err != nil || captain == nil || captain.SupporterBadgeTiers == "" {
return ""
}
// Check if user is the captain (owner)
if captain.OwnerDID == userDID {
if captain.HasSupporterBadge("owner") {
return "owner"
}
return ""
// If user is the owner and "owner" badge is enabled, show it
if captain.OwnerDID == userDID && captain.HasSupporterBadge("owner") {
return "owner"
}
// Look up crew membership for this user on this hold
@@ -163,7 +176,7 @@ func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string {
}
for _, m := range memberships {
if m.HoldDID == holdDID && m.Tier != "" {
if normalizeDidWeb(m.HoldDID) == holdDID && m.Tier != "" {
if captain.HasSupporterBadge(m.Tier) {
return m.Tier
}
@@ -174,6 +187,22 @@ func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string {
return ""
}
// GetCrewHoldDID returns the hold DID from the user's most recent crew membership.
// Used as a fallback when the user's DefaultHoldDID is not cached.
func GetCrewHoldDID(db DBTX, memberDID string) string {
var holdDID string
err := db.QueryRow(`
SELECT hold_did FROM hold_crew_members
WHERE member_did = ?
ORDER BY updated_at DESC
LIMIT 1
`, memberDID).Scan(&holdDID)
if err != nil {
return ""
}
return holdDID
}
// ListHoldDIDs returns all known hold DIDs from the cache
func ListHoldDIDs(db DBTX) ([]string, error) {
query := `
+70 -2
View File
@@ -25,6 +25,7 @@ type HoldDisplay struct {
Region string `json:"region"`
Membership string `json:"membership"`
Permissions []string `json:"permissions,omitempty"`
Status string `json:"status"` // "" = unknown, "online", "offline"
}
// SettingsHandler handles the settings page
@@ -85,6 +86,17 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// Check cached health status (non-blocking, nil = no data yet)
if h.HealthChecker != nil {
if status := h.HealthChecker.GetCachedStatus(hold.HoldDID); status != nil {
if status.Reachable {
display.Status = "online"
} else {
display.Status = "offline"
}
}
}
// Add to data map for JavaScript
holdDataMap[hold.HoldDID] = display
@@ -220,6 +232,9 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
holdDID = r.FormValue("hold_endpoint")
}
// Normalize did:web encoding (form URL-decoding can strip %3A → colon)
holdDID = atproto.NormalizeDID(holdDID)
// Validate hold DID if provided and database is available
if holdDID != "" && h.DB != nil {
// Check if user has access to this hold
@@ -273,9 +288,17 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
if h.DB != nil {
_ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID)
// Refresh captain record for the selected hold so badge tiers are available immediately
// Ensure crew membership on the new hold (auto-registers on open holds)
// and refresh captain/crew cache so badge tiers are available immediately
if holdDID != "" {
go refreshCaptainRecord(holdDID, h.DB)
go func() {
storage.EnsureCrewMembership(
context.Background(), client, h.Refresher,
holdDID, middleware.GetGlobalAuthorizer(),
)
refreshCaptainRecord(holdDID, h.DB)
refreshCrewMembership(holdDID, user.DID, h.DB)
}()
}
}
@@ -334,3 +357,48 @@ func refreshCaptainRecord(holdDID string, dbConn *sql.DB) {
slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers)
}
// refreshCrewMembership fetches a user's crew record from a hold and caches it locally.
// Uses the deterministic rkey to do a direct O(1) lookup.
func refreshCrewMembership(holdDID, userDID string, dbConn *sql.DB) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
if err != nil {
slog.Debug("Failed to resolve hold URL for crew refresh", "hold_did", holdDID, "error", err)
return
}
rkey := atproto.CrewRecordKey(userDID)
holdClient := atproto.NewClient(holdURL, holdDID, "")
record, err := holdClient.GetRecord(ctx, atproto.CrewCollection, rkey)
if err != nil {
slog.Debug("No crew record found for user on hold", "hold_did", holdDID, "user_did", userDID, "error", err)
return
}
var crewRecord atproto.CrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
slog.Debug("Failed to parse crew record for refresh", "hold_did", holdDID, "error", err)
return
}
permJSON, _ := json.Marshal(crewRecord.Permissions)
member := &db.CrewMember{
HoldDID: holdDID,
MemberDID: crewRecord.Member,
Rkey: rkey,
Role: crewRecord.Role,
Permissions: string(permJSON),
Tier: crewRecord.Tier,
AddedAt: crewRecord.AddedAt,
}
if err := db.UpsertCrewMember(dbConn, member); err != nil {
slog.Debug("Failed to cache crew membership on refresh", "hold_did", holdDID, "user_did", userDID, "error", err)
return
}
slog.Info("Refreshed crew membership for user on hold", "hold_did", holdDID, "user_did", userDID, "tier", crewRecord.Tier)
}
+9 -2
View File
@@ -64,8 +64,15 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check for supporter badge on user's default hold
var supporterBadge string
if hasProfile && h.ReadOnlyDB != nil && viewedUser.DefaultHoldDID != "" {
supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, viewedUser.DefaultHoldDID)
if h.ReadOnlyDB != nil {
holdDID := viewedUser.DefaultHoldDID
if holdDID == "" {
// Fallback: check if user has any crew membership
holdDID = db.GetCrewHoldDID(h.ReadOnlyDB, viewedUser.DID)
}
if holdDID != "" {
supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, holdDID)
}
}
// Build page meta
-1
View File
@@ -402,4 +402,3 @@ func (h *BaseUIHandler) renderAlert(w http.ResponseWriter, alertType, message st
"Message": message,
})
}
+3 -3
View File
@@ -410,10 +410,10 @@
@apply inline-flex items-stretch text-xs font-semibold leading-none;
}
.vuln-strip > span {
@apply px-2 py-1 min-w-[1.75rem] text-center cursor-pointer;
@apply px-2 py-1 min-w-7 text-center cursor-pointer;
}
.vuln-strip > span:first-child { @apply rounded-l; }
.vuln-strip > span:last-child { @apply rounded-r; }
.vuln-strip > span:first-child { @apply rounded-l-sm; }
.vuln-strip > span:last-child { @apply rounded-r-sm; }
.vuln-box-critical { background-color: oklch(45% 0.16 20); color: oklch(97% 0.01 20); }
.vuln-box-high { background-color: oklch(58% 0.18 35); color: oklch(97% 0.01 35); }
.vuln-box-medium { background-color: oklch(72% 0.15 70); color: oklch(25% 0.05 70); }
+18 -6
View File
@@ -155,8 +155,8 @@
{{ if .OwnedHolds }}
<optgroup label="Your Holds">
{{ range .OwnedHolds }}
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
</option>
{{ end }}
</optgroup>
@@ -165,8 +165,8 @@
{{ if .CrewHolds }}
<optgroup label="Crew Member">
{{ range .CrewHolds }}
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
</option>
{{ end }}
</optgroup>
@@ -175,8 +175,8 @@
{{ if .EligibleHolds }}
<optgroup label="Open Registration">
{{ range .EligibleHolds }}
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
</option>
{{ end }}
</optgroup>
@@ -199,6 +199,8 @@
<dd id="hold-did" class="font-mono"></dd>
<dt class="text-base-content/70">Region:</dt>
<dd id="hold-region"></dd>
<dt class="text-base-content/70">Status:</dt>
<dd id="hold-status-badge"></dd>
<dt class="text-base-content/70">Your Access:</dt>
<dd id="hold-access"></dd>
</dl>
@@ -408,6 +410,16 @@
document.getElementById('hold-did').textContent = hold.did;
document.getElementById('hold-region').textContent = hold.region || 'Unknown';
// Set status badge
const statusEl = document.getElementById('hold-status-badge');
if (hold.status === 'offline') {
statusEl.innerHTML = '<span class="badge badge-sm badge-warning">Offline</span>';
} else if (hold.status === 'online') {
statusEl.innerHTML = '<span class="badge badge-sm badge-success">Online</span>';
} else {
statusEl.innerHTML = '<span class="text-base-content/60">Unknown</span>';
}
// Set access level with badge
const accessEl = document.getElementById('hold-access');
const accessLabel = {
@@ -28,7 +28,7 @@
<legend class="label"><span class="label-text">Trigger Events</span></legend>
<div class="space-y-2 mt-1">
{{ range .TriggerInfo }}
<label class="flex items-start gap-3 cursor-pointer{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50{{ end }}">
<label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}">
<input type="checkbox" name="trigger_{{ if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}"
class="checkbox checkbox-sm mt-0.5"
{{ if .AlwaysAvailable }}checked{{ end }}
+6 -6
View File
@@ -667,12 +667,12 @@ func (t *TagRecord) GetManifestDigest() (string, error) {
// Stored in the hold's embedded PDS to identify the hold owner and settings
// Uses CBOR encoding for efficient storage in hold's carstore
type CaptainRecord struct {
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles
+1
View File
@@ -33,6 +33,7 @@ var KnownRelays = []KnownRelay{
{Name: "Hayes", URL: "https://relay.hayescmd.net"},
{Name: "Xero", URL: "https://relay.xero.systems"},
{Name: "Feeds Blue", URL: "https://relay.feeds.blue"},
{Name: "Waow", URL: "https://relay.waow.tech"},
}
// RelayHTTPError indicates the relay responded with a non-200 status code.
+15
View File
@@ -118,6 +118,21 @@ func ResolveHoldDIDToURL(ctx context.Context, did string) (string, error) {
return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
}
// NormalizeDID ensures did:web DIDs use %3A encoding for port separators
// per the did:web spec. Other DID methods are returned as-is.
// e.g., "did:web:172.28.0.3:8080" → "did:web:172.28.0.3%3A8080"
func NormalizeDID(did string) string {
if !strings.HasPrefix(did, "did:web:") {
return did
}
host := strings.TrimPrefix(did, "did:web:")
// Only fix bare colons — skip if already percent-encoded
if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
host = strings.Replace(host, ":", "%3A", 1)
}
return "did:web:" + host
}
// didWebToURL converts a did:web DID to its base URL.
// did:web:example.com → https://example.com
// did:web:172.28.0.3%3A8080 → http://172.28.0.3:8080
+4 -4
View File
@@ -199,7 +199,7 @@ func TestAuthorizer_IsCrewMember_Success(t *testing.T) {
holdPDS := createTestHoldPDS(t, ownerDID, false, false)
ctx := context.Background()
_, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read", "blob:write"})
_, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read", "blob:write"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -224,7 +224,7 @@ func TestAuthorizer_IsCrewMember_NotMember(t *testing.T) {
holdPDS := createTestHoldPDS(t, ownerDID, false, false)
ctx := context.Background()
_, err := holdPDS.AddCrewMember(ctx, "did:plc:bob456", "member", []string{"blob:read"})
_, err := holdPDS.AddCrewMember(ctx, "did:plc:bob456", "member", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -325,7 +325,7 @@ func TestAuthorizer_CheckWriteAccess_CrewMember(t *testing.T) {
holdPDS := createTestHoldPDS(t, ownerDID, false, true)
ctx := context.Background()
_, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read", "blob:write"})
_, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read", "blob:write"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -350,7 +350,7 @@ func TestAuthorizer_CheckReadAccess_CrewMember(t *testing.T) {
holdPDS := createTestHoldPDS(t, ownerDID, false, false)
ctx := context.Background()
_, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read"})
_, err := holdPDS.AddCrewMember(ctx, userDID, "member", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
+9 -25
View File
@@ -238,8 +238,13 @@ func (ui *AdminUI) handleCrewAdd(w http.ResponseWriter, r *http.Request) {
role = "member"
}
// Add crew member
_, err := ui.pds.AddCrewMember(ctx, did, role, permissions)
// Resolve default tier from quota config if not specified
if tier == "" && ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
tier = ui.quotaMgr.GetDefaultTier()
}
// Add crew member with tier
_, err := ui.pds.AddCrewMember(ctx, did, role, permissions, tier)
if err != nil {
slog.Error("Failed to add crew member", "did", did, "error", err)
setFlash(w, r, "error", "Failed to add crew member: "+err.Error())
@@ -247,18 +252,6 @@ func (ui *AdminUI) handleCrewAdd(w http.ResponseWriter, r *http.Request) {
return
}
// Update tier if specified and different from default
defaultTier := "default"
if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
defaultTier = ui.quotaMgr.GetDefaultTier()
}
if tier != "" && tier != defaultTier {
if err := ui.pds.UpdateCrewMemberTier(ctx, did, tier); err != nil {
slog.Warn("Failed to set tier for new crew member", "did", did, "tier", tier, "error", err)
}
}
session := getSessionFromContext(ctx)
slog.Info("Crew member added via admin panel",
"did", did,
@@ -362,21 +355,12 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) {
return
}
// Create new record with updated values
if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions); err != nil {
// Create new record with updated values (including tier)
if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions, tier); err != nil {
setFlash(w, r, "error", "Failed to recreate crew record: "+err.Error())
http.Redirect(w, r, "/admin#crew", http.StatusFound)
return
}
// Re-apply tier to new record
if tier != "" {
if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil {
slog.Error("failed to update crew member tier", "error", err, "path", r.URL.Path)
http.Error(w, "Failed to update tier", http.StatusInternalServerError)
return
}
}
}
session := getSessionFromContext(ctx)
+7 -9
View File
@@ -155,21 +155,19 @@ func (ui *AdminUI) handleCrewImport(w http.ResponseWriter, r *http.Request) {
role = "member"
}
if _, err := ui.pds.AddCrewMember(ctx, entry.DID, role, entry.Permissions); err != nil {
// Resolve tier: use entry tier if specified, otherwise default from quota config
tier := entry.Tier
if tier == "" && ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
tier = ui.quotaMgr.GetDefaultTier()
}
if _, err := ui.pds.AddCrewMember(ctx, entry.DID, role, entry.Permissions, tier); err != nil {
result.Status = "error"
result.Reason = err.Error()
results = append(results, result)
continue
}
// Set tier if specified
if entry.Tier != "" && ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
if err := ui.pds.UpdateCrewMemberTier(ctx, entry.DID, entry.Tier); err != nil {
slog.Warn("Failed to set tier for imported crew member",
"did", entry.DID, "tier", entry.Tier, "error", err)
}
}
result.Status = "added"
result.Handle = resolveHandle(ctx, entry.DID)
results = append(results, result)
+4 -4
View File
@@ -247,10 +247,10 @@ func ExampleYAML() ([]byte, error) {
// Populate example quota tiers so operators see the structure
cfg.Quota = quota.Config{
Tiers: map[string]quota.TierConfig{
"deckhand": {Quota: "5GB", MaxWebhooks: 1},
"bosun": {Quota: "50GB", ScanOnPush: true, MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true},
"quartermaster": {Quota: "100GB", ScanOnPush: true, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true},
Tiers: []quota.TierConfig{
{Name: "deckhand", Quota: "5GB", MaxWebhooks: 1},
{Name: "bosun", Quota: "50GB", ScanOnPush: true, MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true},
{Name: "quartermaster", Quota: "100GB", ScanOnPush: true, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true},
},
Defaults: quota.DefaultsConfig{
NewCrewTier: "deckhand",
+10 -10
View File
@@ -512,7 +512,7 @@ func TestValidateBlobWriteAccess_ServiceToken_CrewWithPermission(t *testing.T) {
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
// Add crew member with blob:write permission
_, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
_, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -565,7 +565,7 @@ func TestValidateBlobWriteAccess_ServiceToken_CrewWithoutPermission(t *testing.T
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
// Add crew member with blob:read permission only (no blob:write)
_, err := pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
_, err := pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -645,14 +645,14 @@ func TestValidateBlobWriteAccess_CrewPermissions(t *testing.T) {
// Add crew member with blob:write permission
writerDID := "did:plc:writer123"
_, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
_, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
// Add crew member without blob:write permission
readerDID := "did:plc:reader123"
_, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
_, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -796,7 +796,7 @@ func TestValidateBlobReadAccess_BlobWriteImpliesRead(t *testing.T) {
// Add crew member with ONLY blob:write permission (no blob:read)
writerDID := "did:plc:writer123"
_, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
_, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
if err != nil {
t.Fatalf("Failed to add crew writer: %v", err)
}
@@ -831,7 +831,7 @@ func TestValidateBlobReadAccess_BlobWriteImpliesRead(t *testing.T) {
// Also verify that crew with only blob:read still works
t.Run("crew with blob:read can read", func(t *testing.T) {
readerDID := "did:plc:reader123"
_, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
_, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew reader: %v", err)
}
@@ -861,7 +861,7 @@ func TestValidateBlobReadAccess_BlobWriteImpliesRead(t *testing.T) {
// Verify crew with neither permission cannot read
t.Run("crew without read or write cannot read", func(t *testing.T) {
noPermDID := "did:plc:noperm123"
_, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"})
_, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -896,14 +896,14 @@ func TestValidateOwnerOrCrewAdmin(t *testing.T) {
// Add crew member with crew:admin permission
adminDID := "did:plc:admin123"
_, err := pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"})
_, err := pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew admin: %v", err)
}
// Add crew member without crew:admin permission
writerDID := "did:plc:writer123"
_, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
_, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"}, "")
if err != nil {
t.Fatalf("Failed to add crew writer: %v", err)
}
@@ -990,7 +990,7 @@ func TestCrewPermissions(t *testing.T) {
// Add all crew members
for _, tt := range tests {
_, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions)
_, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions, "")
if err != nil {
t.Fatalf("Failed to add crew member %s: %v", tt.name, err)
}
+2 -1
View File
@@ -17,12 +17,13 @@ import (
// AddCrewMember adds a new crew member to the hold and commits to carstore
// Uses deterministic rkey based on member DID hash for O(1) lookups and automatic deduplication
// If the member already exists, updates their record (upsert behavior)
func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string, tier string) (cid.Cid, error) {
crewRecord := &atproto.CrewRecord{
Type: atproto.CrewCollection,
Member: memberDID,
Role: role,
Permissions: permissions,
Tier: tier,
AddedAt: time.Now().Format(time.RFC3339),
}
+8 -8
View File
@@ -18,7 +18,7 @@ func TestAddCrewMember(t *testing.T) {
role := "writer"
permissions := []string{"blob:read", "blob:write"}
recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions)
recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "")
if err != nil {
t.Fatalf("AddCrewMember failed: %v", err)
}
@@ -71,7 +71,7 @@ func TestGetCrewMember(t *testing.T) {
role := "reader"
permissions := []string{"blob:read"}
_, err := pds.AddCrewMember(ctx, memberDID, role, permissions)
_, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "")
if err != nil {
t.Fatalf("AddCrewMember failed: %v", err)
}
@@ -174,7 +174,7 @@ func TestListCrewMembers_Multiple(t *testing.T) {
}
for _, m := range members {
_, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions)
_, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions, "")
if err != nil {
t.Fatalf("AddCrewMember failed for %s: %v", m.did, err)
}
@@ -230,7 +230,7 @@ func TestRemoveCrewMember(t *testing.T) {
// Add crew member
memberDID := "did:plc:alice123"
_, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read", "blob:write"})
_, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read", "blob:write"}, "")
if err != nil {
t.Fatalf("AddCrewMember failed: %v", err)
}
@@ -301,7 +301,7 @@ func TestRemoveCrewMember_Multiple(t *testing.T) {
}
for _, did := range dids {
_, err := pds.AddCrewMember(ctx, did, "writer", []string{"blob:read"})
_, err := pds.AddCrewMember(ctx, did, "writer", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("AddCrewMember failed for %s: %v", did, err)
}
@@ -447,7 +447,7 @@ func TestCrewMemberWithKey_Structure(t *testing.T) {
// Add crew member
memberDID := "did:plc:alice123"
_, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read"})
_, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("AddCrewMember failed: %v", err)
}
@@ -489,7 +489,7 @@ func TestAddCrewMember_DidWeb(t *testing.T) {
role := "writer"
permissions := []string{"blob:read", "blob:write"}
recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions)
recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "")
if err != nil {
t.Fatalf("AddCrewMember failed with did:web: %v", err)
}
@@ -553,7 +553,7 @@ func TestListCrewMembers_MixedDIDs(t *testing.T) {
}
for _, m := range members {
_, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions)
_, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions, "")
if err != nil {
t.Fatalf("AddCrewMember failed for %s: %v", m.did, err)
}
+7 -7
View File
@@ -355,9 +355,9 @@ func TestGetQuotaForUserWithTier_OwnerUnlimited(t *testing.T) {
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
tiers:
deckhand:
- name: deckhand
quota: 5GB
bosun:
- name: bosun
quota: 50GB
defaults:
@@ -428,9 +428,9 @@ func TestGetQuotaForUserWithTier_CrewWithDefaultTier(t *testing.T) {
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
tiers:
deckhand:
- name: deckhand
quota: 5GB
bosun:
- name: bosun
quota: 50GB
defaults:
@@ -502,9 +502,9 @@ func TestGetQuotaForUserWithTier_CrewWithExplicitTier(t *testing.T) {
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
tiers:
deckhand:
- name: deckhand
quota: 5GB
bosun:
- name: bosun
quota: 50GB
defaults:
@@ -656,7 +656,7 @@ func TestGetQuotaForUserWithTier_DeduplicatesBlobs(t *testing.T) {
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
tiers:
deckhand:
- name: deckhand
quota: 5GB
defaults:
+1 -1
View File
@@ -288,7 +288,7 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, cfg Bootst
"region", cfg.Region)
// Add hold owner as first crew member with admin role
_, err = p.AddCrewMember(ctx, cfg.OwnerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
_, err = p.AddCrewMember(ctx, cfg.OwnerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}, "")
if err != nil {
return fmt.Errorf("failed to add owner as crew member: %w", err)
}
+2 -2
View File
@@ -421,7 +421,7 @@ func TestBootstrap_MixedDIDs(t *testing.T) {
// Add did:web crew member
webMember := "did:web:bob.example.com"
_, err = pds.AddCrewMember(ctx, webMember, "writer", []string{"blob:read", "blob:write"})
_, err = pds.AddCrewMember(ctx, webMember, "writer", []string{"blob:read", "blob:write"}, "")
if err != nil {
t.Fatalf("AddCrewMember failed with did:web: %v", err)
}
@@ -488,7 +488,7 @@ func TestBootstrap_CrewWithoutCaptain(t *testing.T) {
// Create crew member WITHOUT captain (unusual state)
ownerDID := "did:plc:alice123"
_, err = pds.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
_, err = pds.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}, "")
if err != nil {
t.Fatalf("AddCrewMember failed: %v", err)
}
+42 -19
View File
@@ -38,12 +38,12 @@ type activeWebhook struct {
// WebhookPayload is the JSON body sent to webhook URLs
type WebhookPayload struct {
Trigger string `json:"trigger"`
HoldDID string `json:"holdDid"`
HoldEndpoint string `json:"holdEndpoint"`
Manifest WebhookManifestInfo `json:"manifest"`
Scan WebhookScanInfo `json:"scan"`
Previous *WebhookVulnCounts `json:"previous"`
Trigger string `json:"trigger"`
HoldDID string `json:"holdDid"`
HoldEndpoint string `json:"holdEndpoint"`
Manifest WebhookManifestInfo `json:"manifest"`
Scan WebhookScanInfo `json:"scan"`
Previous *WebhookVulnCounts `json:"previous"`
}
// WebhookManifestInfo describes the scanned manifest
@@ -56,8 +56,8 @@ type WebhookManifestInfo struct {
// WebhookScanInfo describes the scan results
type WebhookScanInfo struct {
ScannedAt string `json:"scannedAt"`
ScannerVersion string `json:"scannerVersion"`
ScannedAt string `json:"scannedAt"`
ScannerVersion string `json:"scannerVersion"`
Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"`
}
@@ -387,6 +387,24 @@ func maskURL(rawURL string) string {
return masked
}
// isCaptain checks if the given DID is the hold captain (owner)
func (h *XRPCHandler) isCaptain(ctx context.Context, did string) bool {
_, captain, err := h.pds.GetCaptainRecord(ctx)
if err != nil {
slog.Debug("isCaptain: failed to get captain record", "error", err)
return false
}
if captain == nil {
slog.Debug("isCaptain: captain record is nil")
return false
}
match := captain.Owner == did
if !match {
slog.Debug("isCaptain: DID mismatch", "captain.Owner", captain.Owner, "user.DID", did)
}
return match
}
// ---- XRPC Handlers ----
// HandleListWebhooks returns webhook configs for a user
@@ -411,9 +429,11 @@ func (h *XRPCHandler) HandleListWebhooks(w http.ResponseWriter, r *http.Request)
return
}
// Get tier limits
// Get tier limits — captains get unlimited access
maxWebhooks, allTriggers := 1, false
if h.quotaMgr != nil {
if h.isCaptain(r.Context(), user.DID) {
maxWebhooks, allTriggers = -1, true
} else if h.quotaMgr != nil {
_, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
tierKey := ""
if crew != nil {
@@ -461,16 +481,19 @@ func (h *XRPCHandler) HandleAddWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// Tier enforcement
tierKey := ""
_, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
if crew != nil {
tierKey = crew.Tier
}
// Tier enforcement — captains get unlimited access
maxWebhooks, allTriggers := 1, false
if h.quotaMgr != nil {
maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
if h.isCaptain(r.Context(), user.DID) {
maxWebhooks, allTriggers = -1, true
} else {
tierKey := ""
_, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
if crew != nil {
tierKey = crew.Tier
}
if h.quotaMgr != nil {
maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
}
}
// Check webhook count limit
+8 -3
View File
@@ -1439,12 +1439,17 @@ func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request)
}
}
// Create new crew record
// Create new crew record with default tier from quota config
defaultTier := ""
if h.quotaMgr != nil && h.quotaMgr.IsEnabled() {
defaultTier = h.quotaMgr.GetDefaultTier()
}
slog.Debug("Creating new crew record",
"did", user.DID,
"role", req.Role,
"permissions", req.Permissions)
recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions)
"permissions", req.Permissions,
"tier", defaultTier)
recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions, defaultTier)
if err != nil {
slog.Error("Failed to create crew record",
"error", err,
+9 -9
View File
@@ -441,7 +441,7 @@ func TestHandleGetRecord(t *testing.T) {
// Verify we can also get crew records
// Add a crew member first
memberDID := "did:plc:testmember"
_, err := handler.pds.AddCrewMember(ctx, memberDID, "reader", []string{"blob:read"})
_, err := handler.pds.AddCrewMember(ctx, memberDID, "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -569,7 +569,7 @@ func TestHandleListRecords(t *testing.T) {
}
for _, did := range memberDIDs {
_, err := handler.pds.AddCrewMember(ctx, did, "reader", []string{"blob:read"})
_, err := handler.pds.AddCrewMember(ctx, did, "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member %s: %v", did, err)
}
@@ -629,7 +629,7 @@ func TestHandleListRecords_Pagination(t *testing.T) {
// Note: Bootstrap already added 1 crew member
// Add 4 more for a total of 5
for i := range 4 {
_, err := handler.pds.AddCrewMember(ctx, "did:plc:member"+string(rune(i+'0')), "reader", []string{"blob:read"})
_, err := handler.pds.AddCrewMember(ctx, "did:plc:member"+string(rune(i+'0')), "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -693,7 +693,7 @@ func TestHandleListRecords_Reverse(t *testing.T) {
// Add crew members
for i := range 3 {
_, err := handler.pds.AddCrewMember(ctx, "did:plc:member"+string(rune(i+'0')), "reader", []string{"blob:read"})
_, err := handler.pds.AddCrewMember(ctx, "did:plc:member"+string(rune(i+'0')), "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -850,7 +850,7 @@ func TestHandleListRecords_Indexed(t *testing.T) {
}
for _, did := range memberDIDs {
_, err := handler.pds.AddCrewMember(ctx, did, "reader", []string{"blob:read"})
_, err := handler.pds.AddCrewMember(ctx, did, "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member %s: %v", did, err)
}
@@ -908,7 +908,7 @@ func TestHandleListRecords_Indexed_Pagination(t *testing.T) {
// Add 4 more crew members for total of 5
for i := range 4 {
_, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"})
_, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -988,7 +988,7 @@ func TestHandleListRecords_Indexed_Reverse(t *testing.T) {
// Add crew members
for i := range 3 {
_, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"})
_, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -1072,7 +1072,7 @@ func TestHandleDeleteRecord(t *testing.T) {
// Add a crew member to delete
memberDID := "did:plc:testmember"
_, err := handler.pds.AddCrewMember(ctx, memberDID, "reader", []string{"blob:read"})
_, err := handler.pds.AddCrewMember(ctx, memberDID, "reader", []string{"blob:read"}, "")
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
@@ -1843,7 +1843,7 @@ func TestHandleRequestCrew_AlreadyMember(t *testing.T) {
// Pre-add the user as a crew member
testUserDID := "did:plc:existinguser123"
_, err = handler.pds.AddCrewMember(ctx, testUserDID, "member", []string{"blob:read", "blob:write"})
_, err = handler.pds.AddCrewMember(ctx, testUserDID, "member", []string{"blob:read", "blob:write"}, "")
if err != nil {
t.Fatalf("Failed to pre-add crew member: %v", err)
}
+42 -24
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
@@ -14,15 +13,28 @@ import (
// Config represents quota tier configuration.
type Config struct {
// Quota tiers keyed by name (e.g. "deckhand", "bosun", "quartermaster").
Tiers map[string]TierConfig `yaml:"tiers" comment:"Quota tiers keyed by rank name. Each tier has a human-readable quota limit."`
// Quota tiers ordered by rank (lowest to highest). Position determines rank.
Tiers []TierConfig `yaml:"tiers" comment:"Quota tiers ordered by rank (lowest to highest). Position determines rank."`
// Default tier settings.
Defaults DefaultsConfig `yaml:"defaults" comment:"Default tier assignment for new crew members."`
}
// TierByName returns the TierConfig for the given name, or nil if not found.
func (c *Config) TierByName(name string) *TierConfig {
for i := range c.Tiers {
if c.Tiers[i].Name == name {
return &c.Tiers[i]
}
}
return nil
}
// TierConfig represents a single tier's configuration.
type TierConfig struct {
// Tier name (e.g. "deckhand", "bosun", "quartermaster").
Name string `yaml:"name" comment:"Tier name used as the key for crew assignments."`
// Human-readable size limit, e.g. "5GB", "50GB", "1TB".
Quota string `yaml:"quota" comment:"Storage quota limit (e.g. \"5GB\", \"50GB\", \"1TB\")."`
@@ -78,12 +90,12 @@ func NewManager(configPath string) (*Manager, error) {
m.config = &cfg
// Parse and resolve all tiers
for name, tier := range cfg.Tiers {
for _, tier := range cfg.Tiers {
bytes, err := ParseHumanBytes(tier.Quota)
if err != nil {
return nil, fmt.Errorf("invalid quota for tier %q: %w", name, err)
return nil, fmt.Errorf("invalid quota for tier %q: %w", tier.Name, err)
}
m.tiers[name] = bytes
m.tiers[tier.Name] = bytes
}
return m, nil
@@ -102,12 +114,12 @@ func NewManagerFromConfig(cfg *Config) (*Manager, error) {
m.config = cfg
for name, tier := range cfg.Tiers {
for _, tier := range cfg.Tiers {
bytes, err := ParseHumanBytes(tier.Quota)
if err != nil {
return nil, fmt.Errorf("invalid quota for tier %q: %w", name, err)
return nil, fmt.Errorf("invalid quota for tier %q: %w", tier.Name, err)
}
m.tiers[name] = bytes
m.tiers[tier.Name] = bytes
}
return m, nil
@@ -193,14 +205,14 @@ func (m *Manager) ScanOnPush(tierKey string) bool {
}
if tierKey != "" {
if tier, ok := m.config.Tiers[tierKey]; ok {
if tier := m.config.TierByName(tierKey); tier != nil {
return tier.ScanOnPush
}
}
// Fall back to default tier
if m.config.Defaults.NewCrewTier != "" {
if tier, ok := m.config.Tiers[m.config.Defaults.NewCrewTier]; ok {
if tier := m.config.TierByName(m.config.Defaults.NewCrewTier); tier != nil {
return tier.ScanOnPush
}
}
@@ -217,7 +229,7 @@ func (m *Manager) WebhookLimits(tierKey string) (maxWebhooks int, allTriggers bo
}
if tierKey != "" {
if tier, ok := m.config.Tiers[tierKey]; ok {
if tier := m.config.TierByName(tierKey); tier != nil {
max := tier.MaxWebhooks
if max == 0 {
max = 1 // default
@@ -228,7 +240,7 @@ func (m *Manager) WebhookLimits(tierKey string) (maxWebhooks int, allTriggers bo
// Fall back to default tier
if m.config.Defaults.NewCrewTier != "" {
if tier, ok := m.config.Tiers[m.config.Defaults.NewCrewTier]; ok {
if tier := m.config.TierByName(m.config.Defaults.NewCrewTier); tier != nil {
max := tier.MaxWebhooks
if max == 0 {
max = 1
@@ -240,8 +252,9 @@ func (m *Manager) WebhookLimits(tierKey string) (maxWebhooks int, allTriggers bo
return 1, false
}
// BadgeTiers returns the names of tiers that have supporter badges enabled.
// Includes "owner" if defaults.owner_badge is true.
// BadgeTiers returns the names of tiers that have supporter badges enabled,
// ordered from highest rank to lowest. Includes "owner" first if
// defaults.owner_badge is true.
// Returns nil if quotas are disabled or no tiers have badges.
func (m *Manager) BadgeTiers() []string {
if !m.IsEnabled() {
@@ -251,12 +264,12 @@ func (m *Manager) BadgeTiers() []string {
if m.config.Defaults.OwnerBadge {
tiers = append(tiers, "owner")
}
for name, tier := range m.config.Tiers {
if tier.SupporterBadge {
tiers = append(tiers, name)
// Iterate in reverse: highest rank first
for i := len(m.config.Tiers) - 1; i >= 0; i-- {
if m.config.Tiers[i].SupporterBadge {
tiers = append(tiers, m.config.Tiers[i].Name)
}
}
sort.Strings(tiers)
return tiers
}
@@ -271,17 +284,22 @@ type TierInfo struct {
Limit *int64
}
// ListTiers returns all configured tiers with their limits
// ListTiers returns all configured tiers with their limits, in rank order
// (lowest to highest).
func (m *Manager) ListTiers() []TierInfo {
if !m.IsEnabled() {
return nil
}
tiers := make([]TierInfo, 0, len(m.tiers))
for key, limit := range m.tiers {
limitCopy := limit // Create copy to take address of
tiers := make([]TierInfo, 0, len(m.config.Tiers))
for _, tier := range m.config.Tiers {
limit, ok := m.tiers[tier.Name]
if !ok {
continue
}
limitCopy := limit
tiers = append(tiers, TierInfo{
Key: key,
Key: tier.Name,
Limit: &limitCopy,
})
}
+74 -22
View File
@@ -111,10 +111,10 @@ func TestNewManagerFromConfig_EmptyTiers(t *testing.T) {
func TestNewManagerFromConfig_WithTiers(t *testing.T) {
cfg := &Config{
Tiers: map[string]TierConfig{
"deckhand": {Quota: "5GB"},
"bosun": {Quota: "50GB"},
"quartermaster": {Quota: "100GB"},
Tiers: []TierConfig{
{Name: "deckhand", Quota: "5GB"},
{Name: "bosun", Quota: "50GB"},
{Name: "quartermaster", Quota: "100GB"},
},
Defaults: DefaultsConfig{
NewCrewTier: "deckhand",
@@ -165,11 +165,11 @@ func TestNewManager_ValidConfig(t *testing.T) {
configContent := `
tiers:
deckhand:
- name: deckhand
quota: 5GB
bosun:
- name: bosun
quota: 50GB
quartermaster:
- name: quartermaster
quota: 100GB
defaults:
@@ -225,9 +225,9 @@ func TestNewManager_FallbackToDefault(t *testing.T) {
configContent := `
tiers:
deckhand:
- name: deckhand
quota: 5GB
quartermaster:
- name: quartermaster
quota: 50GB
defaults:
@@ -278,7 +278,7 @@ func TestNewManager_InvalidQuotaSize(t *testing.T) {
configContent := `
tiers:
deckhand:
- name: deckhand
quota: invalid_size
defaults:
@@ -307,10 +307,10 @@ func TestScanOnPush_Disabled(t *testing.T) {
func TestScanOnPush_ExplicitTier(t *testing.T) {
cfg := &Config{
Tiers: map[string]TierConfig{
"deckhand": {Quota: "5GB", ScanOnPush: false},
"bosun": {Quota: "50GB", ScanOnPush: true},
"quartermaster": {Quota: "100GB", ScanOnPush: true},
Tiers: []TierConfig{
{Name: "deckhand", Quota: "5GB", ScanOnPush: false},
{Name: "bosun", Quota: "50GB", ScanOnPush: true},
{Name: "quartermaster", Quota: "100GB", ScanOnPush: true},
},
Defaults: DefaultsConfig{NewCrewTier: "deckhand"},
}
@@ -332,9 +332,9 @@ func TestScanOnPush_ExplicitTier(t *testing.T) {
func TestScanOnPush_FallbackToDefault(t *testing.T) {
cfg := &Config{
Tiers: map[string]TierConfig{
"deckhand": {Quota: "5GB", ScanOnPush: false},
"bosun": {Quota: "50GB", ScanOnPush: true},
Tiers: []TierConfig{
{Name: "deckhand", Quota: "5GB", ScanOnPush: false},
{Name: "bosun", Quota: "50GB", ScanOnPush: true},
},
Defaults: DefaultsConfig{NewCrewTier: "deckhand"},
}
@@ -356,8 +356,8 @@ func TestScanOnPush_FallbackToDefault(t *testing.T) {
func TestScanOnPush_FallbackToDefaultTrue(t *testing.T) {
cfg := &Config{
Tiers: map[string]TierConfig{
"deckhand": {Quota: "5GB", ScanOnPush: true},
Tiers: []TierConfig{
{Name: "deckhand", Quota: "5GB", ScanOnPush: true},
},
Defaults: DefaultsConfig{NewCrewTier: "deckhand"},
}
@@ -375,8 +375,8 @@ func TestScanOnPush_FallbackToDefaultTrue(t *testing.T) {
func TestScanOnPush_ZeroValue(t *testing.T) {
// When scan_on_push is omitted from config, Go zero value = false
cfg := &Config{
Tiers: map[string]TierConfig{
"deckhand": {Quota: "5GB"}, // ScanOnPush not set
Tiers: []TierConfig{
{Name: "deckhand", Quota: "5GB"}, // ScanOnPush not set
},
Defaults: DefaultsConfig{NewCrewTier: "deckhand"},
}
@@ -396,7 +396,7 @@ func TestNewManager_NoDefaultTier(t *testing.T) {
configContent := `
tiers:
quartermaster:
- name: quartermaster
quota: 50GB
defaults:
@@ -426,3 +426,55 @@ defaults:
t.Errorf("expected 50GB limit for quartermaster, got %d", *limit)
}
}
func TestBadgeTiers_RankOrder(t *testing.T) {
cfg := &Config{
Tiers: []TierConfig{
{Name: "deckhand", Quota: "5GB"},
{Name: "bosun", Quota: "50GB", SupporterBadge: true},
{Name: "quartermaster", Quota: "100GB", SupporterBadge: true},
},
Defaults: DefaultsConfig{OwnerBadge: true},
}
m, err := NewManagerFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
tiers := m.BadgeTiers()
// Expected: owner first, then highest rank first
expected := []string{"owner", "quartermaster", "bosun"}
if len(tiers) != len(expected) {
t.Fatalf("got %v, want %v", tiers, expected)
}
for i := range expected {
if tiers[i] != expected[i] {
t.Errorf("tiers[%d] = %q, want %q", i, tiers[i], expected[i])
}
}
}
func TestListTiers_PreservesOrder(t *testing.T) {
cfg := &Config{
Tiers: []TierConfig{
{Name: "deckhand", Quota: "5GB"},
{Name: "bosun", Quota: "50GB"},
{Name: "quartermaster", Quota: "100GB"},
},
}
m, err := NewManagerFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
tiers := m.ListTiers()
if len(tiers) != 3 {
t.Fatalf("expected 3 tiers, got %d", len(tiers))
}
expected := []string{"deckhand", "bosun", "quartermaster"}
for i, name := range expected {
if tiers[i].Key != name {
t.Errorf("tiers[%d].Key = %q, want %q", i, tiers[i].Key, name)
}
}
}