mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-01 15:56:58 +00:00
fix tier and supporter badge assignments. normalize did:web adresses with ports. various minor fixes
This commit is contained in:
+38
-35
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 := `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -402,4 +402,3 @@ func (h *BaseUIHandler) renderAlert(w http.ResponseWriter, alertType, message st
|
||||
"Message": message,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user