+1
-1
cmd/hold/main.go
+1
-1
cmd/hold/main.go
···
106
106
os.Exit(1)
107
107
}
108
108
if quotaMgr.IsEnabled() {
109
-
slog.Info("Quota enforcement enabled", "berths", quotaMgr.BerthCount(), "defaultBerth", quotaMgr.GetDefaultBerth())
109
+
slog.Info("Quota enforcement enabled", "tiers", quotaMgr.TierCount(), "defaultTier", quotaMgr.GetDefaultTier())
110
110
} else {
111
111
slog.Info("Quota enforcement disabled (no quotas.yaml found)")
112
112
}
+10
-10
deploy/quotas.yaml
+10
-10
deploy/quotas.yaml
···
2
2
# Copy this file to quotas.yaml to enable quota enforcement.
3
3
# If quotas.yaml doesn't exist, quotas are disabled (unlimited for all users).
4
4
5
-
# Berths define quota tiers using nautical crew ranks.
6
-
# Each berth has a quota limit specified in human-readable format.
5
+
# Tiers define quota levels using nautical crew ranks.
6
+
# Each tier has a quota limit specified in human-readable format.
7
7
# Supported units: B, KB, MB, GB, TB, PB (case-insensitive)
8
-
berths:
8
+
tiers:
9
9
# Entry-level crew - suitable for new or casual users
10
10
deckhand:
11
11
quota: 5GB
···
18
18
quartermaster:
19
19
quota: 100GB
20
20
21
-
# You can add custom berths with any name:
21
+
# You can add custom tiers with any name:
22
22
# unlimited_crew:
23
23
# quota: 1TB
24
24
25
25
defaults:
26
-
# Default berth assigned to new crew members who don't have an explicit berth.
27
-
# This berth must exist in the berths section above.
28
-
new_crew_berth: deckhand
26
+
# Default tier assigned to new crew members who don't have an explicit tier.
27
+
# This tier must exist in the tiers section above.
28
+
new_crew_tier: deckhand
29
29
30
30
# Notes:
31
-
# - The hold captain (owner) always has unlimited quota regardless of berths.
32
-
# - Crew members can be assigned a specific berth in their crew record.
33
-
# - If a crew member's berth doesn't exist in config, they fall back to the default.
31
+
# - The hold captain (owner) always has unlimited quota regardless of tiers.
32
+
# - Crew members can be assigned a specific tier in their crew record.
33
+
# - If a crew member's tier doesn't exist in config, they fall back to the default.
34
34
# - Quota is calculated per-user by summing unique blob sizes (deduplicated).
35
35
# - Quota is checked when pushing manifests (after blobs are already uploaded).
+11
-11
docs/QUOTAS.md
+11
-11
docs/QUOTAS.md
···
507
507
- Email/webhook notifications
508
508
- Grace period before hard enforcement
509
509
510
-
### 3. Berth-Based Quotas (Implemented)
510
+
### 3. Tier-Based Quotas (Implemented)
511
511
512
-
ATCR uses nautical-themed "berths" for quota tiers, configured via `quotas.yaml`:
512
+
ATCR uses quota tiers to limit storage per crew member, configured via `quotas.yaml`:
513
513
514
514
```yaml
515
515
# quotas.yaml
516
-
berths:
516
+
tiers:
517
517
deckhand: # Entry-level crew
518
518
quota: 5GB
519
519
bosun: # Mid-level crew
···
522
522
quota: 100GB
523
523
524
524
defaults:
525
-
new_crew_berth: deckhand # Default berth for new crew members
525
+
new_crew_tier: deckhand # Default tier for new crew members
526
526
```
527
527
528
-
| Berth | Limit | Description |
529
-
|-------|-------|-------------|
528
+
| Tier | Limit | Description |
529
+
|------|-------|-------------|
530
530
| deckhand | 5 GB | Entry-level crew member |
531
531
| bosun | 50 GB | Mid-level crew member |
532
532
| quartermaster | 100 GB | Senior crew member |
533
533
| owner (captain) | Unlimited | Hold owner always has unlimited |
534
534
535
-
**Berth Resolution:**
535
+
**Tier Resolution:**
536
536
1. If user is captain (owner) → unlimited
537
-
2. If crew member has explicit berth → use that berth's limit
538
-
3. If crew member has no berth → use `defaults.new_crew_berth`
539
-
4. If default berth not found → unlimited
537
+
2. If crew member has explicit tier → use that tier's limit
538
+
3. If crew member has no tier → use `defaults.new_crew_tier`
539
+
4. If default tier not found → unlimited
540
540
541
541
**Crew Record Example:**
542
542
```json
···
545
545
"member": "did:plc:alice123",
546
546
"role": "writer",
547
547
"permissions": ["blob:write"],
548
-
"berth": "bosun",
548
+
"tier": "bosun",
549
549
"addedAt": "2026-01-04T12:00:00Z"
550
550
}
551
551
```
+2
-2
lexicons/io/atcr/hold/crew.json
+2
-2
lexicons/io/atcr/hold/crew.json
···
29
29
"maxLength": 64
30
30
}
31
31
},
32
-
"berth": {
32
+
"tier": {
33
33
"type": "string",
34
-
"description": "Optional berth (nautical rank) for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster'). If empty, uses defaults.new_crew_berth from quotas.yaml.",
34
+
"description": "Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster'). If empty, uses defaults.new_crew_tier from quotas.yaml.",
35
35
"maxLength": 32
36
36
},
37
37
"addedAt": {
+3
-3
pkg/appview/handlers/storage.go
+3
-3
pkg/appview/handlers/storage.go
···
26
26
UniqueBlobs int `json:"uniqueBlobs"`
27
27
TotalSize int64 `json:"totalSize"`
28
28
Limit *int64 `json:"limit,omitempty"` // nil = unlimited
29
-
Berth string `json:"berth,omitempty"` // e.g., "deckhand", "bosun", "owner"
29
+
Tier string `json:"tier,omitempty"` // e.g., "deckhand", "bosun", "owner"
30
30
}
31
31
32
32
func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···
110
110
HasLimit bool
111
111
HumanLimit string
112
112
UsagePercent int
113
-
Berth string
113
+
Tier string
114
114
}{
115
115
UniqueBlobs: stats.UniqueBlobs,
116
116
TotalSize: stats.TotalSize,
···
118
118
HasLimit: hasLimit,
119
119
HumanLimit: humanLimit,
120
120
UsagePercent: usagePercent,
121
-
Berth: stats.Berth,
121
+
Tier: stats.Tier,
122
122
}
123
123
124
124
w.Header().Set("Content-Type", "text/html")
+6
-6
pkg/appview/templates/pages/settings.html
+6
-6
pkg/appview/templates/pages/settings.html
···
31
31
32
32
<!-- Storage Usage Section -->
33
33
<section class="settings-section storage-section">
34
-
<h2>Storage Usage</h2>
34
+
<h2>Stowage</h2>
35
35
<p>Estimated storage usage on your default hold.</p>
36
36
<div id="storage-stats" hx-get="/api/storage" hx-trigger="load" hx-swap="innerHTML">
37
37
<p><i data-lucide="loader-2" class="spin"></i> Loading...</p>
···
293
293
white-space: nowrap;
294
294
}
295
295
296
-
/* Berth Badge */
297
-
.storage-section .berth-badge {
296
+
/* Tier Badge */
297
+
.storage-section .tier-badge {
298
298
text-transform: capitalize;
299
299
padding: 0.125rem 0.5rem;
300
300
border-radius: 4px;
···
302
302
background: var(--accent-bg, #e0f2fe);
303
303
color: var(--accent, #0369a1);
304
304
}
305
-
.storage-section .berth-owner {
305
+
.storage-section .tier-owner {
306
306
background: #fef3c7;
307
307
color: #92400e;
308
308
}
309
-
.storage-section .berth-quartermaster {
309
+
.storage-section .tier-quartermaster {
310
310
background: #dcfce7;
311
311
color: #166534;
312
312
}
313
-
.storage-section .berth-bosun {
313
+
.storage-section .tier-bosun {
314
314
background: #e0e7ff;
315
315
color: #3730a3;
316
316
}
+3
-3
pkg/appview/templates/partials/storage_stats.html
+3
-3
pkg/appview/templates/partials/storage_stats.html
···
1
1
{{ define "storage_stats" }}
2
2
<div class="storage-stats">
3
-
{{ if .Berth }}
3
+
{{ if .Tier }}
4
4
<div class="stat-row">
5
-
<span class="stat-label">Berth:</span>
6
-
<span class="stat-value berth-badge berth-{{ .Berth }}">{{ .Berth }}</span>
5
+
<span class="stat-label">Tier:</span>
6
+
<span class="stat-value tier-badge tier-{{ .Tier }}">{{ .Tier }}</span>
7
7
</div>
8
8
{{ end }}
9
9
<div class="stat-row">
+33
-33
pkg/atproto/cbor_gen.go
+33
-33
pkg/atproto/cbor_gen.go
···
27
27
cw := cbg.NewCborWriter(w)
28
28
fieldCount := 6
29
29
30
-
if t.Berth == "" {
30
+
if t.Tier == "" {
31
31
fieldCount--
32
32
}
33
33
···
58
58
return err
59
59
}
60
60
61
+
// t.Tier (string) (string)
62
+
if t.Tier != "" {
63
+
64
+
if len("tier") > 8192 {
65
+
return xerrors.Errorf("Value in field \"tier\" was too long")
66
+
}
67
+
68
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tier"))); err != nil {
69
+
return err
70
+
}
71
+
if _, err := cw.WriteString(string("tier")); err != nil {
72
+
return err
73
+
}
74
+
75
+
if len(t.Tier) > 8192 {
76
+
return xerrors.Errorf("Value in field t.Tier was too long")
77
+
}
78
+
79
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Tier))); err != nil {
80
+
return err
81
+
}
82
+
if _, err := cw.WriteString(string(t.Tier)); err != nil {
83
+
return err
84
+
}
85
+
}
86
+
61
87
// t.Type (string) (string)
62
88
if len("$type") > 8192 {
63
89
return xerrors.Errorf("Value in field \"$type\" was too long")
···
79
105
}
80
106
if _, err := cw.WriteString(string(t.Type)); err != nil {
81
107
return err
82
-
}
83
-
84
-
// t.Berth (string) (string)
85
-
if t.Berth != "" {
86
-
87
-
if len("berth") > 8192 {
88
-
return xerrors.Errorf("Value in field \"berth\" was too long")
89
-
}
90
-
91
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("berth"))); err != nil {
92
-
return err
93
-
}
94
-
if _, err := cw.WriteString(string("berth")); err != nil {
95
-
return err
96
-
}
97
-
98
-
if len(t.Berth) > 8192 {
99
-
return xerrors.Errorf("Value in field t.Berth was too long")
100
-
}
101
-
102
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Berth))); err != nil {
103
-
return err
104
-
}
105
-
if _, err := cw.WriteString(string(t.Berth)); err != nil {
106
-
return err
107
-
}
108
108
}
109
109
110
110
// t.Member (string) (string)
···
240
240
241
241
t.Role = string(sval)
242
242
}
243
-
// t.Type (string) (string)
244
-
case "$type":
243
+
// t.Tier (string) (string)
244
+
case "tier":
245
245
246
246
{
247
247
sval, err := cbg.ReadStringWithMax(cr, 8192)
···
249
249
return err
250
250
}
251
251
252
-
t.Type = string(sval)
252
+
t.Tier = string(sval)
253
253
}
254
-
// t.Berth (string) (string)
255
-
case "berth":
254
+
// t.Type (string) (string)
255
+
case "$type":
256
256
257
257
{
258
258
sval, err := cbg.ReadStringWithMax(cr, 8192)
···
260
260
return err
261
261
}
262
262
263
-
t.Berth = string(sval)
263
+
t.Type = string(sval)
264
264
}
265
265
// t.Member (string) (string)
266
266
case "member":
+2
-2
pkg/atproto/lexicon.go
+2
-2
pkg/atproto/lexicon.go
···
594
594
Member string `json:"member" cborgen:"member"`
595
595
Role string `json:"role" cborgen:"role"`
596
596
Permissions []string `json:"permissions" cborgen:"permissions"`
597
-
Berth string `json:"berth,omitempty" cborgen:"berth,omitempty"` // Optional berth for quota limits (nautical rank)
598
-
AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
597
+
Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster')
598
+
AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
599
599
}
600
600
601
601
// LayerRecord represents metadata about a container layer stored in the hold
+2
-2
pkg/hold/oci/xrpc.go
+2
-2
pkg/hold/oci/xrpc.go
···
24
24
pds *pds.HoldPDS
25
25
httpClient pds.HTTPClient
26
26
enableBlueskyPosts bool
27
-
quotaMgr *quota.Manager // Quota manager for berth-based limits
27
+
quotaMgr *quota.Manager // Quota manager for tier-based limits
28
28
}
29
29
30
30
// NewXRPCHandler creates a new OCI XRPC handler
···
281
281
if operation == "push" {
282
282
// Soft limit check: block if ALREADY over quota
283
283
// (blobs already uploaded to S3 by this point, no sense rejecting)
284
-
stats, err := h.pds.GetQuotaForUserWithBerth(ctx, req.UserDID, h.quotaMgr)
284
+
stats, err := h.pds.GetQuotaForUserWithTier(ctx, req.UserDID, h.quotaMgr)
285
285
if err == nil && stats.Limit != nil && stats.TotalSize > *stats.Limit {
286
286
slog.Warn("Quota exceeded for push",
287
287
"userDid", req.UserDID,
+12
-12
pkg/hold/pds/layer.go
+12
-12
pkg/hold/pds/layer.go
···
67
67
UniqueBlobs int `json:"uniqueBlobs"`
68
68
TotalSize int64 `json:"totalSize"`
69
69
Limit *int64 `json:"limit,omitempty"` // nil = unlimited
70
-
Berth string `json:"berth,omitempty"` // nautical rank for quota tier
70
+
Tier string `json:"tier,omitempty"` // quota tier (e.g., 'deckhand', 'bosun', 'quartermaster')
71
71
}
72
72
73
73
// GetQuotaForUser calculates storage quota for a specific user
···
164
164
}, nil
165
165
}
166
166
167
-
// GetQuotaForUserWithBerth calculates quota with berth-aware limits
168
-
// It returns the base quota stats plus the berth limit and berth name.
167
+
// GetQuotaForUserWithTier calculates quota with tier-aware limits
168
+
// It returns the base quota stats plus the tier limit and tier name.
169
169
// Captain (owner) always has unlimited quota.
170
-
func (p *HoldPDS) GetQuotaForUserWithBerth(ctx context.Context, userDID string, quotaMgr *quota.Manager) (*QuotaStats, error) {
170
+
func (p *HoldPDS) GetQuotaForUserWithTier(ctx context.Context, userDID string, quotaMgr *quota.Manager) (*QuotaStats, error) {
171
171
// Get base stats
172
172
stats, err := p.GetQuotaForUser(ctx, userDID)
173
173
if err != nil {
···
182
182
// Check if user is captain (owner) - always unlimited
183
183
_, captain, err := p.GetCaptainRecord(ctx)
184
184
if err == nil && captain.Owner == userDID {
185
-
stats.Berth = "owner"
185
+
stats.Tier = "owner"
186
186
// Limit remains nil (unlimited)
187
187
return stats, nil
188
188
}
189
189
190
-
// Get crew record to find berth
191
-
crewBerth := p.getCrewBerth(ctx, userDID)
190
+
// Get crew record to find tier
191
+
crewTier := p.getCrewTier(ctx, userDID)
192
192
193
193
// Resolve limit from quota manager
194
-
stats.Limit = quotaMgr.GetBerthLimit(crewBerth)
195
-
stats.Berth = quotaMgr.GetBerthName(crewBerth)
194
+
stats.Limit = quotaMgr.GetTierLimit(crewTier)
195
+
stats.Tier = quotaMgr.GetTierName(crewTier)
196
196
197
197
return stats, nil
198
198
}
199
199
200
-
// getCrewBerth returns the berth for a crew member, or empty string if not found
201
-
func (p *HoldPDS) getCrewBerth(ctx context.Context, userDID string) string {
200
+
// getCrewTier returns the tier for a crew member, or empty string if not found
201
+
func (p *HoldPDS) getCrewTier(ctx context.Context, userDID string) string {
202
202
crewMembers, err := p.ListCrewMembers(ctx)
203
203
if err != nil {
204
204
return ""
···
206
206
207
207
for _, member := range crewMembers {
208
208
if member.Record.Member == userDID {
209
-
return member.Record.Berth
209
+
return member.Record.Tier
210
210
}
211
211
}
212
212
+53
-53
pkg/hold/pds/layer_test.go
+53
-53
pkg/hold/pds/layer_test.go
···
328
328
return pds, cleanup
329
329
}
330
330
331
-
// addCrewMemberWithBerth adds a crew member with a specific berth (nautical rank)
332
-
func addCrewMemberWithBerth(t *testing.T, pds *HoldPDS, memberDID, role string, permissions []string, berth string) {
331
+
// addCrewMemberWithTier adds a crew member with a specific tier
332
+
func addCrewMemberWithTier(t *testing.T, pds *HoldPDS, memberDID, role string, permissions []string, tier string) {
333
333
t.Helper()
334
334
335
335
crewRecord := &atproto.CrewRecord{
···
337
337
Member: memberDID,
338
338
Role: role,
339
339
Permissions: permissions,
340
-
Berth: berth,
340
+
Tier: tier,
341
341
AddedAt: "2026-01-04T12:00:00Z",
342
342
}
343
343
344
344
_, _, err := pds.repomgr.CreateRecord(sharedCtx, pds.uid, atproto.CrewCollection, crewRecord)
345
345
if err != nil {
346
-
t.Fatalf("Failed to add crew member with berth: %v", err)
346
+
t.Fatalf("Failed to add crew member with tier: %v", err)
347
347
}
348
348
}
349
349
350
-
func TestGetQuotaForUserWithBerth_OwnerUnlimited(t *testing.T) {
350
+
func TestGetQuotaForUserWithTier_OwnerUnlimited(t *testing.T) {
351
351
ownerDID := "did:plc:owner123"
352
352
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
353
353
defer cleanup()
···
358
358
tmpDir := t.TempDir()
359
359
configPath := filepath.Join(tmpDir, "quotas.yaml")
360
360
configContent := `
361
-
berths:
361
+
tiers:
362
362
deckhand:
363
363
quota: 5GB
364
364
bosun:
365
365
quota: 50GB
366
366
367
367
defaults:
368
-
new_crew_berth: deckhand
368
+
new_crew_tier: deckhand
369
369
`
370
370
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
371
371
t.Fatalf("Failed to write quota config: %v", err)
···
391
391
}
392
392
393
393
// Get quota for owner
394
-
stats, err := pds.GetQuotaForUserWithBerth(ctx, ownerDID, quotaMgr)
394
+
stats, err := pds.GetQuotaForUserWithTier(ctx, ownerDID, quotaMgr)
395
395
if err != nil {
396
-
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
396
+
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
397
397
}
398
398
399
399
// Owner should have unlimited quota (nil limit)
···
401
401
t.Errorf("Expected nil limit for owner, got %d", *stats.Limit)
402
402
}
403
403
404
-
// Berth should be "owner"
405
-
if stats.Berth != "owner" {
406
-
t.Errorf("Expected berth 'owner', got %q", stats.Berth)
404
+
// Tier should be "owner"
405
+
if stats.Tier != "owner" {
406
+
t.Errorf("Expected tier 'owner', got %q", stats.Tier)
407
407
}
408
408
409
409
// Should have 3 unique blobs
···
420
420
t.Logf("Owner quota stats: %+v", stats)
421
421
}
422
422
423
-
func TestGetQuotaForUserWithBerth_CrewWithDefaultBerth(t *testing.T) {
423
+
func TestGetQuotaForUserWithTier_CrewWithDefaultTier(t *testing.T) {
424
424
ownerDID := "did:plc:owner456"
425
425
crewDID := "did:plc:crew123"
426
426
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···
432
432
tmpDir := t.TempDir()
433
433
configPath := filepath.Join(tmpDir, "quotas.yaml")
434
434
configContent := `
435
-
berths:
435
+
tiers:
436
436
deckhand:
437
437
quota: 5GB
438
438
bosun:
439
439
quota: 50GB
440
440
441
441
defaults:
442
-
new_crew_berth: deckhand
442
+
new_crew_tier: deckhand
443
443
`
444
444
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
445
445
t.Fatalf("Failed to write quota config: %v", err)
···
450
450
t.Fatalf("Failed to create quota manager: %v", err)
451
451
}
452
452
453
-
// Add crew member with no berth (should use default)
454
-
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "")
453
+
// Add crew member with no tier (should use default)
454
+
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "")
455
455
456
456
// Create layer records for crew member
457
457
for i := range 2 {
···
468
468
}
469
469
470
470
// Get quota for crew member
471
-
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
471
+
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
472
472
if err != nil {
473
-
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
473
+
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
474
474
}
475
475
476
-
// Should have 5GB limit (deckhand berth)
476
+
// Should have 5GB limit (deckhand tier)
477
477
expectedLimit := int64(5 * 1024 * 1024 * 1024)
478
478
if stats.Limit == nil {
479
479
t.Fatal("Expected non-nil limit for crew member")
···
482
482
t.Errorf("Expected limit %d, got %d", expectedLimit, *stats.Limit)
483
483
}
484
484
485
-
// Berth should be "deckhand"
486
-
if stats.Berth != "deckhand" {
487
-
t.Errorf("Expected berth 'deckhand', got %q", stats.Berth)
485
+
// Tier should be "deckhand"
486
+
if stats.Tier != "deckhand" {
487
+
t.Errorf("Expected tier 'deckhand', got %q", stats.Tier)
488
488
}
489
489
490
490
// Should have 2 unique blobs
···
492
492
t.Errorf("Expected 2 unique blobs, got %d", stats.UniqueBlobs)
493
493
}
494
494
495
-
t.Logf("Crew (deckhand berth) quota stats: %+v", stats)
495
+
t.Logf("Crew (deckhand tier) quota stats: %+v", stats)
496
496
}
497
497
498
-
func TestGetQuotaForUserWithBerth_CrewWithExplicitBerth(t *testing.T) {
498
+
func TestGetQuotaForUserWithTier_CrewWithExplicitTier(t *testing.T) {
499
499
ownerDID := "did:plc:owner789"
500
500
crewDID := "did:plc:bosuncrew456"
501
501
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···
507
507
tmpDir := t.TempDir()
508
508
configPath := filepath.Join(tmpDir, "quotas.yaml")
509
509
configContent := `
510
-
berths:
510
+
tiers:
511
511
deckhand:
512
512
quota: 5GB
513
513
bosun:
514
514
quota: 50GB
515
515
516
516
defaults:
517
-
new_crew_berth: deckhand
517
+
new_crew_tier: deckhand
518
518
`
519
519
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
520
520
t.Fatalf("Failed to write quota config: %v", err)
···
525
525
t.Fatalf("Failed to create quota manager: %v", err)
526
526
}
527
527
528
-
// Add crew member with explicit "bosun" berth
529
-
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
528
+
// Add crew member with explicit "bosun" tier
529
+
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
530
530
531
531
// Create layer records for crew member
532
532
record := atproto.NewLayerRecord(
···
541
541
}
542
542
543
543
// Get quota for crew member
544
-
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
544
+
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
545
545
if err != nil {
546
-
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
546
+
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
547
547
}
548
548
549
-
// Should have 50GB limit (bosun berth)
549
+
// Should have 50GB limit (bosun tier)
550
550
expectedLimit := int64(50 * 1024 * 1024 * 1024)
551
551
if stats.Limit == nil {
552
552
t.Fatal("Expected non-nil limit for crew member")
···
555
555
t.Errorf("Expected limit %d, got %d", expectedLimit, *stats.Limit)
556
556
}
557
557
558
-
// Berth should be "bosun"
559
-
if stats.Berth != "bosun" {
560
-
t.Errorf("Expected berth 'bosun', got %q", stats.Berth)
558
+
// Tier should be "bosun"
559
+
if stats.Tier != "bosun" {
560
+
t.Errorf("Expected tier 'bosun', got %q", stats.Tier)
561
561
}
562
562
563
-
t.Logf("Crew (bosun berth) quota stats: %+v", stats)
563
+
t.Logf("Crew (bosun tier) quota stats: %+v", stats)
564
564
}
565
565
566
-
func TestGetQuotaForUserWithBerth_NoQuotaManager(t *testing.T) {
566
+
func TestGetQuotaForUserWithTier_NoQuotaManager(t *testing.T) {
567
567
ownerDID := "did:plc:ownerabc"
568
568
crewDID := "did:plc:crewabc"
569
569
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···
572
572
ctx := sharedCtx
573
573
574
574
// Add crew member
575
-
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "deckhand")
575
+
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "deckhand")
576
576
577
577
// Create layer record
578
578
record := atproto.NewLayerRecord(
···
587
587
}
588
588
589
589
// Get quota with nil quota manager (no enforcement)
590
-
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, nil)
590
+
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, nil)
591
591
if err != nil {
592
-
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
592
+
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
593
593
}
594
594
595
595
// Should have nil limit (unlimited)
···
597
597
t.Errorf("Expected nil limit when quota manager is nil, got %d", *stats.Limit)
598
598
}
599
599
600
-
// Berth should be empty
601
-
if stats.Berth != "" {
602
-
t.Errorf("Expected empty berth, got %q", stats.Berth)
600
+
// Tier should be empty
601
+
if stats.Tier != "" {
602
+
t.Errorf("Expected empty tier, got %q", stats.Tier)
603
603
}
604
604
605
605
t.Logf("No quota manager stats: %+v", stats)
606
606
}
607
607
608
-
func TestGetQuotaForUserWithBerth_DisabledQuotas(t *testing.T) {
608
+
func TestGetQuotaForUserWithTier_DisabledQuotas(t *testing.T) {
609
609
ownerDID := "did:plc:ownerdef"
610
610
crewDID := "did:plc:crewdef"
611
611
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···
624
624
}
625
625
626
626
// Add crew member
627
-
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
627
+
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
628
628
629
629
// Create layer record
630
630
record := atproto.NewLayerRecord(
···
639
639
}
640
640
641
641
// Get quota with disabled quota manager
642
-
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
642
+
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
643
643
if err != nil {
644
-
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
644
+
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
645
645
}
646
646
647
647
// Should have nil limit (unlimited when quotas disabled)
···
652
652
t.Logf("Disabled quotas stats: %+v", stats)
653
653
}
654
654
655
-
func TestGetQuotaForUserWithBerth_DeduplicatesBlobs(t *testing.T) {
655
+
func TestGetQuotaForUserWithTier_DeduplicatesBlobs(t *testing.T) {
656
656
ownerDID := "did:plc:ownerghi"
657
657
crewDID := "did:plc:crewghi"
658
658
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···
664
664
tmpDir := t.TempDir()
665
665
configPath := filepath.Join(tmpDir, "quotas.yaml")
666
666
configContent := `
667
-
berths:
667
+
tiers:
668
668
deckhand:
669
669
quota: 5GB
670
670
671
671
defaults:
672
-
new_crew_berth: deckhand
672
+
new_crew_tier: deckhand
673
673
`
674
674
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
675
675
t.Fatalf("Failed to write quota config: %v", err)
···
681
681
}
682
682
683
683
// Add crew member
684
-
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "")
684
+
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "")
685
685
686
686
// Create multiple layer records with same digest (should be deduplicated)
687
687
digest := "sha256:duplicatelayer"
···
699
699
}
700
700
701
701
// Get quota
702
-
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
702
+
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
703
703
if err != nil {
704
-
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
704
+
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
705
705
}
706
706
707
707
// Should have 1 unique blob (deduplicated)
+2
-2
pkg/hold/pds/xrpc.go
+2
-2
pkg/hold/pds/xrpc.go
···
1537
1537
return
1538
1538
}
1539
1539
1540
-
// Get quota stats with berth-aware limits
1541
-
stats, err := h.pds.GetQuotaForUserWithBerth(r.Context(), userDID, h.quotaMgr)
1540
+
// Get quota stats with tier-aware limits
1541
+
stats, err := h.pds.GetQuotaForUserWithTier(r.Context(), userDID, h.quotaMgr)
1542
1542
if err != nil {
1543
1543
slog.Error("Failed to get quota", "userDid", userDID, "error", err)
1544
1544
http.Error(w, fmt.Sprintf("failed to get quota: %v", err), http.StatusInternalServerError)
+43
-43
pkg/hold/quota/config.go
+43
-43
pkg/hold/quota/config.go
···
13
13
14
14
// Config represents the quotas.yaml configuration
15
15
type Config struct {
16
-
Berths map[string]BerthConfig `yaml:"berths"`
17
-
Defaults DefaultsConfig `yaml:"defaults"`
16
+
Tiers map[string]TierConfig `yaml:"tiers"`
17
+
Defaults DefaultsConfig `yaml:"defaults"`
18
18
}
19
19
20
-
// BerthConfig represents a single berth's configuration
21
-
type BerthConfig struct {
20
+
// TierConfig represents a single tier's configuration
21
+
type TierConfig struct {
22
22
Quota string `yaml:"quota"` // Human-readable size: "5GB", "50GB", etc.
23
23
}
24
24
25
25
// DefaultsConfig represents default settings
26
26
type DefaultsConfig struct {
27
-
NewCrewBerth string `yaml:"new_crew_berth"`
27
+
NewCrewTier string `yaml:"new_crew_tier"`
28
28
}
29
29
30
-
// Manager manages quota configuration and berth resolution
30
+
// Manager manages quota configuration and tier resolution
31
31
type Manager struct {
32
32
config *Config
33
-
berths map[string]int64 // resolved berth name -> bytes
33
+
tiers map[string]int64 // resolved tier name -> bytes
34
34
}
35
35
36
36
// NewManager creates a quota manager, loading config from file if present
37
37
func NewManager(configPath string) (*Manager, error) {
38
38
m := &Manager{
39
-
berths: make(map[string]int64),
39
+
tiers: make(map[string]int64),
40
40
}
41
41
42
42
// Try to load config file
···
56
56
57
57
m.config = &cfg
58
58
59
-
// Parse and resolve all berths
60
-
for name, berth := range cfg.Berths {
61
-
bytes, err := ParseHumanBytes(berth.Quota)
59
+
// Parse and resolve all tiers
60
+
for name, tier := range cfg.Tiers {
61
+
bytes, err := ParseHumanBytes(tier.Quota)
62
62
if err != nil {
63
-
return nil, fmt.Errorf("invalid quota for berth %q: %w", name, err)
63
+
return nil, fmt.Errorf("invalid quota for tier %q: %w", name, err)
64
64
}
65
-
m.berths[name] = bytes
65
+
m.tiers[name] = bytes
66
66
}
67
67
68
68
return m, nil
···
73
73
return m.config != nil
74
74
}
75
75
76
-
// GetBerthLimit resolves the quota limit for a berth key
77
-
// Returns nil for unlimited (captain, no config, or berth not found with no default)
76
+
// GetTierLimit resolves the quota limit for a tier key
77
+
// Returns nil for unlimited (captain, no config, or tier not found with no default)
78
78
//
79
79
// Resolution order:
80
80
// 1. If quotas disabled → nil (unlimited)
81
-
// 2. If berthKey provided and found → return that berth's limit
82
-
// 3. If berthKey not found or empty → use defaults.new_crew_berth
83
-
// 4. If default berth not found → nil (unlimited)
84
-
func (m *Manager) GetBerthLimit(berthKey string) *int64 {
81
+
// 2. If tierKey provided and found → return that tier's limit
82
+
// 3. If tierKey not found or empty → use defaults.new_crew_tier
83
+
// 4. If default tier not found → nil (unlimited)
84
+
func (m *Manager) GetTierLimit(tierKey string) *int64 {
85
85
if !m.IsEnabled() {
86
86
return nil
87
87
}
88
88
89
-
// Try the provided berth key first
90
-
if berthKey != "" {
91
-
if limit, ok := m.berths[berthKey]; ok {
89
+
// Try the provided tier key first
90
+
if tierKey != "" {
91
+
if limit, ok := m.tiers[tierKey]; ok {
92
92
return &limit
93
93
}
94
94
}
95
95
96
-
// Fall back to default berth
97
-
if m.config.Defaults.NewCrewBerth != "" {
98
-
if limit, ok := m.berths[m.config.Defaults.NewCrewBerth]; ok {
96
+
// Fall back to default tier
97
+
if m.config.Defaults.NewCrewTier != "" {
98
+
if limit, ok := m.tiers[m.config.Defaults.NewCrewTier]; ok {
99
99
return &limit
100
100
}
101
101
}
102
102
103
-
// No valid berth found - unlimited
103
+
// No valid tier found - unlimited
104
104
return nil
105
105
}
106
106
107
-
// GetBerthName resolves the berth name for a berth key
108
-
// Returns the actual berth name being used (after fallback resolution)
109
-
func (m *Manager) GetBerthName(berthKey string) string {
107
+
// GetTierName resolves the tier name for a tier key
108
+
// Returns the actual tier name being used (after fallback resolution)
109
+
func (m *Manager) GetTierName(tierKey string) string {
110
110
if !m.IsEnabled() {
111
111
return ""
112
112
}
113
113
114
-
// Try the provided berth key first
115
-
if berthKey != "" {
116
-
if _, ok := m.berths[berthKey]; ok {
117
-
return berthKey
114
+
// Try the provided tier key first
115
+
if tierKey != "" {
116
+
if _, ok := m.tiers[tierKey]; ok {
117
+
return tierKey
118
118
}
119
119
}
120
120
121
-
// Fall back to default berth
122
-
if m.config.Defaults.NewCrewBerth != "" {
123
-
if _, ok := m.berths[m.config.Defaults.NewCrewBerth]; ok {
124
-
return m.config.Defaults.NewCrewBerth
121
+
// Fall back to default tier
122
+
if m.config.Defaults.NewCrewTier != "" {
123
+
if _, ok := m.tiers[m.config.Defaults.NewCrewTier]; ok {
124
+
return m.config.Defaults.NewCrewTier
125
125
}
126
126
}
127
127
128
128
return ""
129
129
}
130
130
131
-
// GetDefaultBerth returns the default berth name for new crew members
132
-
func (m *Manager) GetDefaultBerth() string {
131
+
// GetDefaultTier returns the default tier name for new crew members
132
+
func (m *Manager) GetDefaultTier() string {
133
133
if m.config == nil {
134
134
return ""
135
135
}
136
-
return m.config.Defaults.NewCrewBerth
136
+
return m.config.Defaults.NewCrewTier
137
137
}
138
138
139
-
// BerthCount returns the number of configured berths
140
-
func (m *Manager) BerthCount() int {
141
-
return len(m.berths)
139
+
// TierCount returns the number of configured tiers
140
+
func (m *Manager) TierCount() int {
141
+
return len(m.tiers)
142
142
}
143
143
144
144
// ParseHumanBytes parses human-readable byte sizes like "5GB", "100MB", "1.5TB"
+34
-34
pkg/hold/quota/config_test.go
+34
-34
pkg/hold/quota/config_test.go
···
97
97
if m.IsEnabled() {
98
98
t.Error("expected quotas to be disabled when file missing")
99
99
}
100
-
if m.GetBerthLimit("anything") != nil {
100
+
if m.GetTierLimit("anything") != nil {
101
101
t.Error("expected nil limit when quotas disabled")
102
102
}
103
103
}
···
107
107
configPath := filepath.Join(tmpDir, "quotas.yaml")
108
108
109
109
configContent := `
110
-
berths:
110
+
tiers:
111
111
deckhand:
112
112
quota: 5GB
113
113
bosun:
···
116
116
quota: 100GB
117
117
118
118
defaults:
119
-
new_crew_berth: deckhand
119
+
new_crew_tier: deckhand
120
120
`
121
121
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
122
122
t.Fatalf("failed to write config: %v", err)
···
131
131
t.Error("expected quotas to be enabled")
132
132
}
133
133
134
-
if m.BerthCount() != 3 {
135
-
t.Errorf("expected 3 berths, got %d", m.BerthCount())
134
+
if m.TierCount() != 3 {
135
+
t.Errorf("expected 3 tiers, got %d", m.TierCount())
136
136
}
137
137
138
-
// Test default berth (empty string)
139
-
limit := m.GetBerthLimit("")
138
+
// Test default tier (empty string)
139
+
limit := m.GetTierLimit("")
140
140
if limit == nil {
141
-
t.Fatal("expected non-nil limit for default berth")
141
+
t.Fatal("expected non-nil limit for default tier")
142
142
}
143
143
if *limit != 5*1024*1024*1024 {
144
144
t.Errorf("expected 5GB limit for default, got %d", *limit)
145
145
}
146
146
147
-
// Test explicit berth
148
-
limit = m.GetBerthLimit("bosun")
147
+
// Test explicit tier
148
+
limit = m.GetTierLimit("bosun")
149
149
if limit == nil {
150
150
t.Fatal("expected non-nil limit for bosun")
151
151
}
···
153
153
t.Errorf("expected 50GB limit for bosun, got %d", *limit)
154
154
}
155
155
156
-
// Test berth name resolution
157
-
if m.GetBerthName("") != "deckhand" {
158
-
t.Errorf("expected berth name 'deckhand' for empty key, got %q", m.GetBerthName(""))
156
+
// Test tier name resolution
157
+
if m.GetTierName("") != "deckhand" {
158
+
t.Errorf("expected tier name 'deckhand' for empty key, got %q", m.GetTierName(""))
159
159
}
160
-
if m.GetBerthName("bosun") != "bosun" {
161
-
t.Errorf("expected berth name 'bosun', got %q", m.GetBerthName("bosun"))
160
+
if m.GetTierName("bosun") != "bosun" {
161
+
t.Errorf("expected tier name 'bosun', got %q", m.GetTierName("bosun"))
162
162
}
163
163
}
164
164
···
167
167
configPath := filepath.Join(tmpDir, "quotas.yaml")
168
168
169
169
configContent := `
170
-
berths:
170
+
tiers:
171
171
deckhand:
172
172
quota: 5GB
173
173
quartermaster:
174
174
quota: 50GB
175
175
176
176
defaults:
177
-
new_crew_berth: deckhand
177
+
new_crew_tier: deckhand
178
178
`
179
179
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
180
180
t.Fatalf("failed to write config: %v", err)
···
185
185
t.Fatalf("failed to load config: %v", err)
186
186
}
187
187
188
-
// Unknown berth should fall back to default
189
-
limit := m.GetBerthLimit("unknown_berth")
188
+
// Unknown tier should fall back to default
189
+
limit := m.GetTierLimit("unknown_tier")
190
190
if limit == nil {
191
-
t.Fatal("expected fallback to default berth")
191
+
t.Fatal("expected fallback to default tier")
192
192
}
193
193
if *limit != 5*1024*1024*1024 {
194
194
t.Errorf("expected 5GB limit from default fallback, got %d", *limit)
195
195
}
196
196
197
-
// Berth name should also fall back
198
-
if m.GetBerthName("unknown_berth") != "deckhand" {
199
-
t.Errorf("expected berth name 'deckhand' for unknown berth, got %q", m.GetBerthName("unknown_berth"))
197
+
// Tier name should also fall back
198
+
if m.GetTierName("unknown_tier") != "deckhand" {
199
+
t.Errorf("expected tier name 'deckhand' for unknown tier, got %q", m.GetTierName("unknown_tier"))
200
200
}
201
201
}
202
202
···
220
220
configPath := filepath.Join(tmpDir, "quotas.yaml")
221
221
222
222
configContent := `
223
-
berths:
223
+
tiers:
224
224
deckhand:
225
225
quota: invalid_size
226
226
227
227
defaults:
228
-
new_crew_berth: deckhand
228
+
new_crew_tier: deckhand
229
229
`
230
230
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
231
231
t.Fatalf("failed to write config: %v", err)
···
237
237
}
238
238
}
239
239
240
-
func TestNewManager_NoDefaultBerth(t *testing.T) {
240
+
func TestNewManager_NoDefaultTier(t *testing.T) {
241
241
tmpDir := t.TempDir()
242
242
configPath := filepath.Join(tmpDir, "quotas.yaml")
243
243
244
244
configContent := `
245
-
berths:
245
+
tiers:
246
246
quartermaster:
247
247
quota: 50GB
248
248
249
249
defaults:
250
-
new_crew_berth: nonexistent
250
+
new_crew_tier: nonexistent
251
251
`
252
252
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
253
253
t.Fatalf("failed to write config: %v", err)
···
258
258
t.Fatalf("failed to load config: %v", err)
259
259
}
260
260
261
-
// Empty berth key with nonexistent default should return nil (unlimited)
262
-
limit := m.GetBerthLimit("")
261
+
// Empty tier key with nonexistent default should return nil (unlimited)
262
+
limit := m.GetTierLimit("")
263
263
if limit != nil {
264
-
t.Error("expected nil limit when default berth doesn't exist")
264
+
t.Error("expected nil limit when default tier doesn't exist")
265
265
}
266
266
267
-
// Explicit berth should still work
268
-
limit = m.GetBerthLimit("quartermaster")
267
+
// Explicit tier should still work
268
+
limit = m.GetTierLimit("quartermaster")
269
269
if limit == nil {
270
-
t.Fatal("expected non-nil limit for quartermaster berth")
270
+
t.Fatal("expected non-nil limit for quartermaster tier")
271
271
}
272
272
if *limit != 50*1024*1024*1024 {
273
273
t.Errorf("expected 50GB limit for quartermaster, got %d", *limit)
+10
-10
quotas.yaml.example
+10
-10
quotas.yaml.example
···
2
2
# Copy this file to quotas.yaml to enable quota enforcement.
3
3
# If quotas.yaml doesn't exist, quotas are disabled (unlimited for all users).
4
4
5
-
# Berths define quota tiers using nautical crew ranks.
6
-
# Each berth has a quota limit specified in human-readable format.
5
+
# Tiers define quota levels using nautical crew ranks.
6
+
# Each tier has a quota limit specified in human-readable format.
7
7
# Supported units: B, KB, MB, GB, TB, PB (case-insensitive)
8
-
berths:
8
+
tiers:
9
9
# Entry-level crew - suitable for new or casual users
10
10
deckhand:
11
11
quota: 5GB
···
18
18
quartermaster:
19
19
quota: 100GB
20
20
21
-
# You can add custom berths with any name:
21
+
# You can add custom tiers with any name:
22
22
# unlimited_crew:
23
23
# quota: 1TB
24
24
25
25
defaults:
26
-
# Default berth assigned to new crew members who don't have an explicit berth.
27
-
# This berth must exist in the berths section above.
28
-
new_crew_berth: deckhand
26
+
# Default tier assigned to new crew members who don't have an explicit tier.
27
+
# This tier must exist in the tiers section above.
28
+
new_crew_tier: deckhand
29
29
30
30
# Notes:
31
-
# - The hold captain (owner) always has unlimited quota regardless of berths.
32
-
# - Crew members can be assigned a specific berth in their crew record.
33
-
# - If a crew member's berth doesn't exist in config, they fall back to the default.
31
+
# - The hold captain (owner) always has unlimited quota regardless of tiers.
32
+
# - Crew members can be assigned a specific tier in their crew record.
33
+
# - If a crew member's tier doesn't exist in config, they fall back to the default.
34
34
# - Quota is calculated per-user by summing unique blob sizes (deduplicated).
35
35
# - Quota is checked when pushing manifests (after blobs are already uploaded).