···1818pkg/appview/static/js/lucide.min.js
19192020# IDE
2121+.zed/
2122.claude/
2223.vscode/
2324.idea/
+6-4
Dockerfile.dev
···11# Development image with Air hot reload
22-# Build: docker build -f Dockerfile.dev -t atcr-appview-dev .
33-# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev
22+# Build: docker build -f Dockerfile.dev -t atcr-dev .
33+# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-dev
44FROM docker.io/golang:1.25.4-trixie
5566+ARG AIR_CONFIG=.air.toml
77+68ENV DEBIAN_FRONTEND=noninteractive
99+ENV AIR_CONFIG=${AIR_CONFIG}
710811RUN apt-get update && \
912 apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl && \
···1720RUN go mod download
18211922# For development: source mounted as volume, Air handles builds
2020-EXPOSE 5000
2121-CMD ["air", "-c", ".air.toml"]
2323+CMD ["sh", "-c", "air -c ${AIR_CONFIG}"]
+1-6
cmd/appview/serve.go
···114114115115 slog.Debug("Base URL for OAuth", "base_url", baseURL)
116116 if testMode {
117117- slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope")
117117+ slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution")
118118 }
119119120120 // Create OAuth client app (automatically configures confidential client for production)
···122122 oauthClientApp, err := oauth.NewClientApp(baseURL, oauthStore, desiredScopes, cfg.Server.OAuthKeyPath, cfg.Server.ClientName)
123123 if err != nil {
124124 return fmt.Errorf("failed to create OAuth client app: %w", err)
125125- }
126126- if testMode {
127127- slog.Info("Using OAuth scopes with transition:generic (test mode)")
128128- } else {
129129- slog.Info("Using OAuth scopes with RPC scope (production mode)")
130125 }
131126132127 // Invalidate sessions with mismatched scopes on startup
+1-1
cmd/usage-report/main.go
···110110 fmt.Println("=== Calculating from hold layer records ===")
111111 fmt.Println("NOTE: May undercount app-password users due to layer record bug")
112112 fmt.Println(" Use --from-manifests for more accurate results")
113113-113113+114114 userUsage, err = calculateFromLayerRecords(baseURL, holdDID)
115115 }
116116
+8-2
docker-compose.yml
···5757 # Storage config comes from env_file (STORAGE_DRIVER, AWS_*, S3_*)
5858 build:
5959 context: .
6060- dockerfile: Dockerfile.hold
6161- image: atcr-hold:latest
6060+ dockerfile: Dockerfile.dev
6161+ args:
6262+ AIR_CONFIG: .air.hold.toml
6363+ image: atcr-hold-dev:latest
6264 container_name: atcr-hold
6365 ports:
6466 - "8080:8080"
6567 volumes:
6868+ # Mount source code for Air hot reload
6969+ - .:/app
7070+ # Cache go modules between rebuilds
7171+ - go-mod-cache:/go/pkg/mod
6672 # PDS data (carstore SQLite + signing keys)
6773 - atcr-hold:/var/lib/atcr-hold
6874 restart: unless-stopped
+1399
docs/ADMIN_PANEL.md
···11+# Hold Admin Panel Implementation Plan
22+33+This document describes the implementation plan for adding an owner-only admin web UI to the ATCR hold service. The admin panel will be embedded directly in the hold service binary for simplified deployment.
44+55+## Table of Contents
66+77+1. [Overview](#overview)
88+2. [Requirements](#requirements)
99+3. [Architecture](#architecture)
1010+4. [File Structure](#file-structure)
1111+5. [Authentication](#authentication)
1212+6. [Session Management](#session-management)
1313+7. [Route Structure](#route-structure)
1414+8. [Feature Implementations](#feature-implementations)
1515+9. [Templates](#templates)
1616+10. [Environment Variables](#environment-variables)
1717+11. [Security Considerations](#security-considerations)
1818+12. [Implementation Phases](#implementation-phases)
1919+13. [Testing Strategy](#testing-strategy)
2020+2121+---
2222+2323+## Overview
2424+2525+The hold admin panel provides a web-based interface for hold owners to:
2626+2727+- **Manage crew members**: Add, remove, edit permissions and quota tiers
2828+- **Configure hold settings**: Toggle public access, open registration, Bluesky posting
2929+- **View usage metrics**: Storage usage per user, top users, repository statistics
3030+- **Monitor quota utilization**: Track tier distribution and usage percentages
3131+3232+The admin panel is owner-only - only the DID that matches `captain.Owner` can access it.
3333+3434+---
3535+3636+## Requirements
3737+3838+### Functional Requirements
3939+4040+1. **Crew Management**
4141+ - List all crew members with their DID, role, permissions, tier, and storage usage
4242+ - Add new crew members with specified permissions and tier
4343+ - Edit existing crew member permissions and tier
4444+ - Remove crew members (with confirmation)
4545+ - Display each crew member's quota utilization percentage
4646+4747+2. **Quota/Tier Management**
4848+ - Display available tiers from `quotas.yaml`
4949+ - Show tier limits and descriptions
5050+ - Allow changing crew member tiers
5151+ - Display current vs limit usage for each user
5252+5353+3. **Usage Metrics**
5454+ - Total storage used across all users
5555+ - Total unique blobs (deduplicated)
5656+ - Number of crew members
5757+ - Top 10/50/100 users by storage consumption
5858+ - Per-repository statistics (pulls, pushes)
5959+6060+4. **Hold Settings**
6161+ - Toggle `public` (allow anonymous blob reads)
6262+ - Toggle `allowAllCrew` (allow any authenticated user to join)
6363+ - Toggle `enableBlueskyPosts` (post to Bluesky on image push)
6464+6565+### Non-Functional Requirements
6666+6767+- **Single binary**: Embedded in hold service, no separate deployment
6868+- **Responsive UI**: Works on desktop and mobile browsers
6969+- **Low latency**: Dashboard loads in <500ms for typical data volumes
7070+- **Minimal dependencies**: Uses Go templates, HTMX for interactivity
7171+7272+---
7373+7474+## Architecture
7575+7676+### High-Level Design
7777+7878+```
7979+┌─────────────────────────────────────────────────────────┐
8080+│ Hold Service │
8181+├─────────────────────────────────────────────────────────┤
8282+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
8383+│ │ XRPC/PDS │ │ OCI XRPC │ │ Admin Panel │ │
8484+│ │ Handlers │ │ Handlers │ │ Handlers │ │
8585+│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │
8686+│ │ │ │ │
8787+│ ┌──────┴────────────────┴───────────────────┴────────┐ │
8888+│ │ Chi Router │ │
8989+│ └─────────────────────────────────────────────────────┘ │
9090+│ │ │
9191+│ ┌────────────────────────┴─────────────────────────┐ │
9292+│ │ Embedded PDS │ │
9393+│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
9494+│ │ │ Captain │ │ Crew │ │ Layer │ │ │
9595+│ │ │ Records │ │ Records │ │ Records │ │ │
9696+│ │ └──────────┘ └──────────┘ └──────────┘ │ │
9797+│ └───────────────────────────────────────────────────┘ │
9898+└─────────────────────────────────────────────────────────┘
9999+```
100100+101101+### Components
102102+103103+1. **AdminUI** - Main struct containing all admin dependencies
104104+2. **Session Store** - SQLite-backed session management (separate from carstore)
105105+3. **OAuth Client** - Reuses `pkg/auth/oauth/` for browser-based login
106106+4. **Auth Middleware** - Validates owner-only access
107107+5. **Handlers** - HTTP handlers for each admin page
108108+6. **Templates** - Go html/template with embed.FS
109109+110110+---
111111+112112+## File Structure
113113+114114+```
115115+pkg/hold/admin/
116116+├── admin.go # Main struct, initialization, route registration
117117+├── auth.go # requireOwner middleware, session validation
118118+├── handlers.go # HTTP handlers for all admin pages
119119+├── session.go # SQLite session store implementation
120120+├── metrics.go # Metrics collection and aggregation
121121+├── templates/
122122+│ ├── base.html # Base layout (html, head, body wrapper)
123123+│ ├── components/
124124+│ │ ├── head.html # CSS/JS includes (HTMX, Lucide icons)
125125+│ │ ├── nav.html # Admin navigation bar
126126+│ │ └── flash.html # Flash message component
127127+│ ├── pages/
128128+│ │ ├── login.html # OAuth login page
129129+│ │ ├── dashboard.html # Metrics overview
130130+│ │ ├── crew.html # Crew list with management actions
131131+│ │ ├── crew_add.html # Add crew member form
132132+│ │ ├── crew_edit.html # Edit crew member form
133133+│ │ └── settings.html # Hold settings page
134134+│ └── partials/
135135+│ ├── crew_row.html # Single crew row (for HTMX updates)
136136+│ ├── usage_stats.html # Usage stats partial
137137+│ └── top_users.html # Top users table partial
138138+└── static/
139139+ ├── css/
140140+ │ └── admin.css # Admin-specific styles
141141+ └── js/
142142+ └── admin.js # Admin-specific JavaScript (if needed)
143143+```
144144+145145+### Files to Modify
146146+147147+| File | Changes |
148148+|------|---------|
149149+| `cmd/hold/main.go` | Add admin UI initialization and route registration |
150150+| `pkg/hold/config.go` | Add `Admin.Enabled` and `Admin.SessionDuration` fields |
151151+| `.env.hold.example` | Document `HOLD_ADMIN_ENABLED`, `HOLD_ADMIN_SESSION_DURATION` |
152152+153153+---
154154+155155+## Authentication
156156+157157+### OAuth Flow for Admin Login
158158+159159+The admin panel uses ATProto OAuth with DPoP for browser-based authentication:
160160+161161+```
162162+┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
163163+│ Browser │ │ Hold │ │ PDS │ │ Owner │
164164+│ │ │ Admin │ │ │ │ │
165165+└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
166166+ │ │ │ │
167167+ │ GET /admin │ │ │
168168+ │───────────────>│ │ │
169169+ │ │ │ │
170170+ │ 302 /admin/auth/login │ │
171171+ │<───────────────│ │ │
172172+ │ │ │ │
173173+ │ GET /admin/auth/login │ │
174174+ │───────────────>│ │ │
175175+ │ │ │ │
176176+ │ Login page (enter handle) │ │
177177+ │<───────────────│ │ │
178178+ │ │ │ │
179179+ │ POST handle │ │ │
180180+ │───────────────>│ │ │
181181+ │ │ │ │
182182+ │ │ StartAuthFlow │ │
183183+ │ │───────────────>│ │
184184+ │ │ │ │
185185+ │ 302 to PDS auth URL │ │
186186+ │<───────────────│ │ │
187187+ │ │ │ │
188188+ │ Authorize in browser │ │
189189+ │────────────────────────────────>│ │
190190+ │ │ │ Approve? │
191191+ │ │ │───────────────>│
192192+ │ │ │ │
193193+ │ │ │ Yes │
194194+ │ │ │<───────────────│
195195+ │ │ │ │
196196+ │ 302 callback with code │ │
197197+ │<────────────────────────────────│ │
198198+ │ │ │ │
199199+ │ GET /admin/auth/oauth/callback │ │
200200+ │───────────────>│ │ │
201201+ │ │ │ │
202202+ │ │ ProcessCallback│ │
203203+ │ │───────────────>│ │
204204+ │ │ │ │
205205+ │ │ OAuth tokens │ │
206206+ │ │<───────────────│ │
207207+ │ │ │ │
208208+ │ │ Check: DID == captain.Owner? │
209209+ │ │─────────────────────────────────│
210210+ │ │ │ │
211211+ │ │ YES: Create session │
212212+ │ │ │ │
213213+ │ 302 /admin + session cookie │ │
214214+ │<───────────────│ │ │
215215+ │ │ │ │
216216+ │ GET /admin (with cookie) │ │
217217+ │───────────────>│ │ │
218218+ │ │ │ │
219219+ │ Dashboard │ │ │
220220+ │<───────────────│ │ │
221221+```
222222+223223+### Owner Validation
224224+225225+The callback handler performs owner validation:
226226+227227+```go
228228+func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
229229+ // Process OAuth callback
230230+ sessionData, err := ui.clientApp.ProcessCallback(r.Context(), r.URL.Query())
231231+ if err != nil {
232232+ ui.renderError(w, "OAuth failed: " + err.Error())
233233+ return
234234+ }
235235+236236+ did := sessionData.AccountDID.String()
237237+238238+ // Get captain record to check owner
239239+ _, captain, err := ui.pds.GetCaptainRecord(r.Context())
240240+ if err != nil {
241241+ ui.renderError(w, "Failed to verify ownership")
242242+ return
243243+ }
244244+245245+ // CRITICAL: Only allow the hold owner
246246+ if did != captain.Owner {
247247+ slog.Warn("Non-owner attempted admin access", "did", did, "owner", captain.Owner)
248248+ ui.renderError(w, "Access denied: Only the hold owner can access the admin panel")
249249+ return
250250+ }
251251+252252+ // Create admin session
253253+ sessionID, err := ui.sessionStore.Create(did, sessionData.Handle, 24*time.Hour)
254254+ if err != nil {
255255+ ui.renderError(w, "Failed to create session")
256256+ return
257257+ }
258258+259259+ // Set session cookie
260260+ http.SetCookie(w, &http.Cookie{
261261+ Name: "hold_admin_session",
262262+ Value: sessionID,
263263+ Path: "/admin",
264264+ MaxAge: 86400, // 24 hours
265265+ HttpOnly: true,
266266+ Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
267267+ SameSite: http.SameSiteLaxMode,
268268+ })
269269+270270+ http.Redirect(w, r, "/admin", http.StatusFound)
271271+}
272272+```
273273+274274+### Auth Middleware
275275+276276+```go
277277+// requireOwner ensures the request is from the hold owner
278278+func (ui *AdminUI) requireOwner(next http.Handler) http.Handler {
279279+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
280280+ // Get session cookie
281281+ cookie, err := r.Cookie("hold_admin_session")
282282+ if err != nil {
283283+ http.Redirect(w, r, "/admin/auth/login?return_to="+r.URL.Path, http.StatusFound)
284284+ return
285285+ }
286286+287287+ // Validate session
288288+ session, err := ui.sessionStore.Get(cookie.Value)
289289+ if err != nil || session == nil || session.ExpiresAt.Before(time.Now()) {
290290+ // Clear invalid cookie
291291+ http.SetCookie(w, &http.Cookie{
292292+ Name: "hold_admin_session",
293293+ Value: "",
294294+ Path: "/admin",
295295+ MaxAge: -1,
296296+ })
297297+ http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
298298+ return
299299+ }
300300+301301+ // Double-check DID still matches captain.Owner
302302+ // (in case ownership transferred while session active)
303303+ _, captain, err := ui.pds.GetCaptainRecord(r.Context())
304304+ if err != nil || session.DID != captain.Owner {
305305+ ui.sessionStore.Delete(cookie.Value)
306306+ http.Error(w, "Access denied: ownership verification failed", http.StatusForbidden)
307307+ return
308308+ }
309309+310310+ // Add session to context for handlers
311311+ ctx := context.WithValue(r.Context(), adminSessionKey, session)
312312+ next.ServeHTTP(w, r.WithContext(ctx))
313313+ })
314314+}
315315+```
316316+317317+---
318318+319319+## Session Management
320320+321321+### Session Store Schema
322322+323323+```sql
324324+-- Admin sessions (browser login state)
325325+CREATE TABLE IF NOT EXISTS admin_sessions (
326326+ id TEXT PRIMARY KEY,
327327+ did TEXT NOT NULL,
328328+ handle TEXT,
329329+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
330330+ expires_at DATETIME NOT NULL,
331331+ last_accessed DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
332332+);
333333+334334+-- Index for cleanup queries
335335+CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at);
336336+CREATE INDEX IF NOT EXISTS idx_admin_sessions_did ON admin_sessions(did);
337337+338338+-- OAuth sessions (indigo library storage)
339339+CREATE TABLE IF NOT EXISTS admin_oauth_sessions (
340340+ session_id TEXT PRIMARY KEY,
341341+ did TEXT NOT NULL,
342342+ data BLOB NOT NULL,
343343+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
344344+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
345345+);
346346+```
347347+348348+### Session Store Interface
349349+350350+```go
351351+// AdminSession represents an authenticated admin session
352352+type AdminSession struct {
353353+ ID string
354354+ DID string
355355+ Handle string
356356+ CreatedAt time.Time
357357+ ExpiresAt time.Time
358358+ LastAccessed time.Time
359359+}
360360+361361+// AdminSessionStore manages admin sessions
362362+type AdminSessionStore struct {
363363+ db *sql.DB
364364+}
365365+366366+func NewAdminSessionStore(dbPath string) (*AdminSessionStore, error)
367367+368368+func (s *AdminSessionStore) Create(did, handle string, duration time.Duration) (string, error)
369369+func (s *AdminSessionStore) Get(sessionID string) (*AdminSession, error)
370370+func (s *AdminSessionStore) Delete(sessionID string) error
371371+func (s *AdminSessionStore) DeleteForDID(did string) error
372372+func (s *AdminSessionStore) Cleanup() error // Remove expired sessions
373373+func (s *AdminSessionStore) Touch(sessionID string) error // Update last_accessed
374374+```
375375+376376+### Database Location
377377+378378+The admin database should be in the same directory as the carstore database:
379379+380380+```go
381381+adminDBPath := filepath.Join(cfg.Database.Path, "admin.db")
382382+```
383383+384384+This keeps all hold data together while maintaining separation between the carstore (ATProto records) and admin sessions.
385385+386386+---
387387+388388+## Route Structure
389389+390390+### Complete Route Table
391391+392392+| Route | Method | Auth | Handler | Description |
393393+|-------|--------|------|---------|-------------|
394394+| `/admin` | GET | Owner | `DashboardHandler` | Main dashboard with metrics |
395395+| `/admin/crew` | GET | Owner | `CrewListHandler` | List all crew members |
396396+| `/admin/crew/add` | GET | Owner | `CrewAddFormHandler` | Add crew form |
397397+| `/admin/crew/add` | POST | Owner | `CrewAddHandler` | Process add crew |
398398+| `/admin/crew/{rkey}` | GET | Owner | `CrewEditFormHandler` | Edit crew form |
399399+| `/admin/crew/{rkey}/update` | POST | Owner | `CrewUpdateHandler` | Process crew update |
400400+| `/admin/crew/{rkey}/delete` | POST | Owner | `CrewDeleteHandler` | Delete crew member |
401401+| `/admin/settings` | GET | Owner | `SettingsHandler` | Hold settings page |
402402+| `/admin/settings/update` | POST | Owner | `SettingsUpdateHandler` | Update settings |
403403+| `/admin/api/stats` | GET | Owner | `StatsAPIHandler` | JSON stats endpoint |
404404+| `/admin/api/top-users` | GET | Owner | `TopUsersAPIHandler` | JSON top users |
405405+| `/admin/auth/login` | GET | Public | `LoginHandler` | Login page |
406406+| `/admin/auth/oauth/authorize` | GET | Public | OAuth authorize | Start OAuth flow |
407407+| `/admin/auth/oauth/callback` | GET | Public | `CallbackHandler` | OAuth callback |
408408+| `/admin/auth/logout` | GET | Owner | `LogoutHandler` | Logout and clear session |
409409+| `/admin/static/*` | GET | Public | Static files | CSS, JS assets |
410410+411411+### Route Registration
412412+413413+```go
414414+func (ui *AdminUI) RegisterRoutes(r chi.Router) {
415415+ // Public routes (login flow)
416416+ r.Get("/admin/auth/login", ui.handleLogin)
417417+ r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize)
418418+ r.Get("/admin/auth/oauth/callback", ui.handleCallback)
419419+420420+ // Static files (public)
421421+ r.Handle("/admin/static/*", http.StripPrefix("/admin/static/", ui.staticHandler()))
422422+423423+ // Protected routes (require owner)
424424+ r.Group(func(r chi.Router) {
425425+ r.Use(ui.requireOwner)
426426+427427+ // Dashboard
428428+ r.Get("/admin", ui.handleDashboard)
429429+430430+ // Crew management
431431+ r.Get("/admin/crew", ui.handleCrewList)
432432+ r.Get("/admin/crew/add", ui.handleCrewAddForm)
433433+ r.Post("/admin/crew/add", ui.handleCrewAdd)
434434+ r.Get("/admin/crew/{rkey}", ui.handleCrewEditForm)
435435+ r.Post("/admin/crew/{rkey}/update", ui.handleCrewUpdate)
436436+ r.Post("/admin/crew/{rkey}/delete", ui.handleCrewDelete)
437437+438438+ // Settings
439439+ r.Get("/admin/settings", ui.handleSettings)
440440+ r.Post("/admin/settings/update", ui.handleSettingsUpdate)
441441+442442+ // API endpoints (for HTMX)
443443+ r.Get("/admin/api/stats", ui.handleStatsAPI)
444444+ r.Get("/admin/api/top-users", ui.handleTopUsersAPI)
445445+446446+ // Logout
447447+ r.Get("/admin/auth/logout", ui.handleLogout)
448448+ })
449449+}
450450+```
451451+452452+---
453453+454454+## Feature Implementations
455455+456456+### Dashboard Handler
457457+458458+```go
459459+type DashboardStats struct {
460460+ TotalCrewMembers int
461461+ TotalBlobs int64
462462+ TotalStorageBytes int64
463463+ TotalStorageHuman string
464464+ TierDistribution map[string]int // tier -> count
465465+ RecentActivity []ActivityEntry
466466+}
467467+468468+func (ui *AdminUI) handleDashboard(w http.ResponseWriter, r *http.Request) {
469469+ ctx := r.Context()
470470+471471+ // Collect basic stats
472472+ crew, _ := ui.pds.ListCrewMembers(ctx)
473473+474474+ stats := DashboardStats{
475475+ TotalCrewMembers: len(crew),
476476+ TierDistribution: make(map[string]int),
477477+ }
478478+479479+ // Count tier distribution
480480+ for _, member := range crew {
481481+ tier := member.Tier
482482+ if tier == "" {
483483+ tier = ui.quotaMgr.GetDefaultTier()
484484+ }
485485+ stats.TierDistribution[tier]++
486486+ }
487487+488488+ // Storage stats (loaded via HTMX to avoid slow initial load)
489489+ // The actual calculation happens in handleStatsAPI
490490+491491+ data := struct {
492492+ AdminPageData
493493+ Stats DashboardStats
494494+ }{
495495+ AdminPageData: ui.newPageData(r),
496496+ Stats: stats,
497497+ }
498498+499499+ ui.templates.ExecuteTemplate(w, "dashboard", data)
500500+}
501501+```
502502+503503+### Crew List Handler
504504+505505+```go
506506+type CrewMemberView struct {
507507+ RKey string
508508+ DID string
509509+ Handle string // Resolved from DID
510510+ Role string
511511+ Permissions []string
512512+ Tier string
513513+ TierLimit string // Human-readable
514514+ CurrentUsage int64
515515+ UsageHuman string
516516+ UsagePercent int
517517+ Plankowner bool
518518+ AddedAt time.Time
519519+}
520520+521521+func (ui *AdminUI) handleCrewList(w http.ResponseWriter, r *http.Request) {
522522+ ctx := r.Context()
523523+524524+ crew, err := ui.pds.ListCrewMembers(ctx)
525525+ if err != nil {
526526+ ui.renderError(w, "Failed to list crew: "+err.Error())
527527+ return
528528+ }
529529+530530+ // Enrich with usage data
531531+ var crewViews []CrewMemberView
532532+ for _, member := range crew {
533533+ view := CrewMemberView{
534534+ RKey: member.RKey,
535535+ DID: member.Member,
536536+ Role: member.Role,
537537+ Permissions: member.Permissions,
538538+ Tier: member.Tier,
539539+ Plankowner: member.Plankowner,
540540+ AddedAt: member.AddedAt,
541541+ }
542542+543543+ // Get tier limit
544544+ if limit := ui.quotaMgr.GetTierLimit(member.Tier); limit != nil {
545545+ view.TierLimit = quota.FormatHumanBytes(*limit)
546546+ } else {
547547+ view.TierLimit = "Unlimited"
548548+ }
549549+550550+ // Get usage (expensive - consider caching)
551551+ usage, _, tier, limit, _ := ui.pds.GetQuotaForUserWithTier(ctx, member.Member, ui.quotaMgr)
552552+ view.CurrentUsage = usage
553553+ view.UsageHuman = quota.FormatHumanBytes(usage)
554554+ if limit != nil && *limit > 0 {
555555+ view.UsagePercent = int(float64(usage) / float64(*limit) * 100)
556556+ }
557557+558558+ crewViews = append(crewViews, view)
559559+ }
560560+561561+ // Sort by usage (highest first)
562562+ sort.Slice(crewViews, func(i, j int) bool {
563563+ return crewViews[i].CurrentUsage > crewViews[j].CurrentUsage
564564+ })
565565+566566+ data := struct {
567567+ AdminPageData
568568+ Crew []CrewMemberView
569569+ Tiers []TierOption
570570+ }{
571571+ AdminPageData: ui.newPageData(r),
572572+ Crew: crewViews,
573573+ Tiers: ui.getTierOptions(),
574574+ }
575575+576576+ ui.templates.ExecuteTemplate(w, "crew", data)
577577+}
578578+```
579579+580580+### Add Crew Handler
581581+582582+```go
583583+func (ui *AdminUI) handleCrewAdd(w http.ResponseWriter, r *http.Request) {
584584+ ctx := r.Context()
585585+586586+ if err := r.ParseForm(); err != nil {
587587+ ui.setFlash(w, "error", "Invalid form data")
588588+ http.Redirect(w, r, "/admin/crew/add", http.StatusFound)
589589+ return
590590+ }
591591+592592+ did := strings.TrimSpace(r.FormValue("did"))
593593+ role := r.FormValue("role")
594594+ tier := r.FormValue("tier")
595595+596596+ // Parse permissions checkboxes
597597+ var permissions []string
598598+ if r.FormValue("perm_read") == "on" {
599599+ permissions = append(permissions, "blob:read")
600600+ }
601601+ if r.FormValue("perm_write") == "on" {
602602+ permissions = append(permissions, "blob:write")
603603+ }
604604+ if r.FormValue("perm_admin") == "on" {
605605+ permissions = append(permissions, "crew:admin")
606606+ }
607607+608608+ // Validate DID format
609609+ if !strings.HasPrefix(did, "did:") {
610610+ ui.setFlash(w, "error", "Invalid DID format")
611611+ http.Redirect(w, r, "/admin/crew/add", http.StatusFound)
612612+ return
613613+ }
614614+615615+ // Add crew member
616616+ _, err := ui.pds.AddCrewMember(ctx, did, role, permissions)
617617+ if err != nil {
618618+ ui.setFlash(w, "error", "Failed to add crew member: "+err.Error())
619619+ http.Redirect(w, r, "/admin/crew/add", http.StatusFound)
620620+ return
621621+ }
622622+623623+ // Set tier if specified
624624+ if tier != "" && tier != ui.quotaMgr.GetDefaultTier() {
625625+ if err := ui.pds.UpdateCrewMemberTier(ctx, did, tier); err != nil {
626626+ slog.Warn("Failed to set tier for new crew member", "did", did, "tier", tier, "error", err)
627627+ }
628628+ }
629629+630630+ ui.setFlash(w, "success", "Crew member added successfully")
631631+ http.Redirect(w, r, "/admin/crew", http.StatusFound)
632632+}
633633+```
634634+635635+### Update Crew Handler
636636+637637+```go
638638+func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) {
639639+ ctx := r.Context()
640640+ rkey := chi.URLParam(r, "rkey")
641641+642642+ if err := r.ParseForm(); err != nil {
643643+ ui.setFlash(w, "error", "Invalid form data")
644644+ http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
645645+ return
646646+ }
647647+648648+ // Get current crew member
649649+ current, err := ui.pds.GetCrewMemberByRKey(ctx, rkey)
650650+ if err != nil {
651651+ ui.setFlash(w, "error", "Crew member not found")
652652+ http.Redirect(w, r, "/admin/crew", http.StatusFound)
653653+ return
654654+ }
655655+656656+ // Parse new values
657657+ role := r.FormValue("role")
658658+ tier := r.FormValue("tier")
659659+660660+ var permissions []string
661661+ if r.FormValue("perm_read") == "on" {
662662+ permissions = append(permissions, "blob:read")
663663+ }
664664+ if r.FormValue("perm_write") == "on" {
665665+ permissions = append(permissions, "blob:write")
666666+ }
667667+ if r.FormValue("perm_admin") == "on" {
668668+ permissions = append(permissions, "crew:admin")
669669+ }
670670+671671+ // Update tier if changed
672672+ if tier != current.Tier {
673673+ if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil {
674674+ ui.setFlash(w, "error", "Failed to update tier: "+err.Error())
675675+ http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
676676+ return
677677+ }
678678+ }
679679+680680+ // For role/permissions changes, need to delete and recreate
681681+ // (ATProto records are immutable, updates require delete+create)
682682+ if role != current.Role || !slicesEqual(permissions, current.Permissions) {
683683+ // Delete old record
684684+ if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil {
685685+ ui.setFlash(w, "error", "Failed to update: "+err.Error())
686686+ http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound)
687687+ return
688688+ }
689689+690690+ // Create new record with updated values
691691+ if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions); err != nil {
692692+ ui.setFlash(w, "error", "Failed to recreate crew record: "+err.Error())
693693+ http.Redirect(w, r, "/admin/crew", http.StatusFound)
694694+ return
695695+ }
696696+697697+ // Re-apply tier to new record
698698+ if tier != "" {
699699+ ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier)
700700+ }
701701+ }
702702+703703+ ui.setFlash(w, "success", "Crew member updated successfully")
704704+ http.Redirect(w, r, "/admin/crew", http.StatusFound)
705705+}
706706+```
707707+708708+### Delete Crew Handler
709709+710710+```go
711711+func (ui *AdminUI) handleCrewDelete(w http.ResponseWriter, r *http.Request) {
712712+ ctx := r.Context()
713713+ rkey := chi.URLParam(r, "rkey")
714714+715715+ // Get crew member to log who was deleted
716716+ member, err := ui.pds.GetCrewMemberByRKey(ctx, rkey)
717717+ if err != nil {
718718+ ui.setFlash(w, "error", "Crew member not found")
719719+ http.Redirect(w, r, "/admin/crew", http.StatusFound)
720720+ return
721721+ }
722722+723723+ // Prevent deleting self (captain)
724724+ session := getAdminSession(ctx)
725725+ if member.Member == session.DID {
726726+ ui.setFlash(w, "error", "Cannot remove yourself from crew")
727727+ http.Redirect(w, r, "/admin/crew", http.StatusFound)
728728+ return
729729+ }
730730+731731+ // Delete
732732+ if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil {
733733+ ui.setFlash(w, "error", "Failed to remove crew member: "+err.Error())
734734+ http.Redirect(w, r, "/admin/crew", http.StatusFound)
735735+ return
736736+ }
737737+738738+ slog.Info("Crew member removed via admin panel", "did", member.Member, "by", session.DID)
739739+740740+ // For HTMX requests, return empty response (row will be removed)
741741+ if r.Header.Get("HX-Request") == "true" {
742742+ w.WriteHeader(http.StatusOK)
743743+ return
744744+ }
745745+746746+ ui.setFlash(w, "success", "Crew member removed")
747747+ http.Redirect(w, r, "/admin/crew", http.StatusFound)
748748+}
749749+```
750750+751751+### Settings Handler
752752+753753+```go
754754+func (ui *AdminUI) handleSettings(w http.ResponseWriter, r *http.Request) {
755755+ ctx := r.Context()
756756+757757+ _, captain, err := ui.pds.GetCaptainRecord(ctx)
758758+ if err != nil {
759759+ ui.renderError(w, "Failed to load settings: "+err.Error())
760760+ return
761761+ }
762762+763763+ data := struct {
764764+ AdminPageData
765765+ Settings struct {
766766+ Public bool
767767+ AllowAllCrew bool
768768+ EnableBlueskyPosts bool
769769+ OwnerDID string
770770+ HoldDID string
771771+ }
772772+ }{
773773+ AdminPageData: ui.newPageData(r),
774774+ }
775775+ data.Settings.Public = captain.Public
776776+ data.Settings.AllowAllCrew = captain.AllowAllCrew
777777+ data.Settings.EnableBlueskyPosts = captain.EnableBlueskyPosts
778778+ data.Settings.OwnerDID = captain.Owner
779779+ data.Settings.HoldDID = ui.pds.DID()
780780+781781+ ui.templates.ExecuteTemplate(w, "settings", data)
782782+}
783783+784784+func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
785785+ ctx := r.Context()
786786+787787+ if err := r.ParseForm(); err != nil {
788788+ ui.setFlash(w, "error", "Invalid form data")
789789+ http.Redirect(w, r, "/admin/settings", http.StatusFound)
790790+ return
791791+ }
792792+793793+ public := r.FormValue("public") == "on"
794794+ allowAllCrew := r.FormValue("allow_all_crew") == "on"
795795+ enablePosts := r.FormValue("enable_bluesky_posts") == "on"
796796+797797+ _, err := ui.pds.UpdateCaptainRecord(ctx, public, allowAllCrew, enablePosts)
798798+ if err != nil {
799799+ ui.setFlash(w, "error", "Failed to update settings: "+err.Error())
800800+ http.Redirect(w, r, "/admin/settings", http.StatusFound)
801801+ return
802802+ }
803803+804804+ ui.setFlash(w, "success", "Settings updated successfully")
805805+ http.Redirect(w, r, "/admin/settings", http.StatusFound)
806806+}
807807+```
808808+809809+### Metrics Handler (for HTMX lazy loading)
810810+811811+```go
812812+func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
813813+ ctx := r.Context()
814814+815815+ // Calculate total storage (expensive operation)
816816+ // Iterate through all layer records
817817+ records, _, err := ui.pds.RecordsIndex().ListRecords(atproto.LayerCollection, 100000, "", true)
818818+ if err != nil {
819819+ http.Error(w, "Failed to load stats", http.StatusInternalServerError)
820820+ return
821821+ }
822822+823823+ var totalSize int64
824824+ uniqueDigests := make(map[string]bool)
825825+ userUsage := make(map[string]int64)
826826+827827+ for _, record := range records {
828828+ var layer atproto.LayerRecord
829829+ if err := json.Unmarshal(record.Value, &layer); err != nil {
830830+ continue
831831+ }
832832+833833+ if !uniqueDigests[layer.Digest] {
834834+ uniqueDigests[layer.Digest] = true
835835+ totalSize += layer.Size
836836+ }
837837+838838+ userUsage[layer.UserDID] += layer.Size
839839+ }
840840+841841+ stats := struct {
842842+ TotalBlobs int `json:"totalBlobs"`
843843+ TotalSize int64 `json:"totalSize"`
844844+ TotalHuman string `json:"totalHuman"`
845845+ }{
846846+ TotalBlobs: len(uniqueDigests),
847847+ TotalSize: totalSize,
848848+ TotalHuman: quota.FormatHumanBytes(totalSize),
849849+ }
850850+851851+ // If HTMX request, return HTML partial
852852+ if r.Header.Get("HX-Request") == "true" {
853853+ data := struct {
854854+ Stats interface{}
855855+ }{Stats: stats}
856856+ ui.templates.ExecuteTemplate(w, "usage_stats", data)
857857+ return
858858+ }
859859+860860+ // Otherwise return JSON
861861+ w.Header().Set("Content-Type", "application/json")
862862+ json.NewEncoder(w).Encode(stats)
863863+}
864864+```
865865+866866+---
867867+868868+## Templates
869869+870870+### Base Layout (templates/base.html)
871871+872872+```html
873873+{{ define "base" }}
874874+<!DOCTYPE html>
875875+<html lang="en">
876876+<head>
877877+ <meta charset="UTF-8">
878878+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
879879+ <title>{{ .Title }} - Hold Admin</title>
880880+ {{ template "head" . }}
881881+</head>
882882+<body>
883883+ {{ template "nav" . }}
884884+885885+ <main class="admin-container">
886886+ {{ template "flash" . }}
887887+ {{ template "content" . }}
888888+ </main>
889889+890890+ <footer class="admin-footer">
891891+ <p>Hold: {{ .HoldDID }}</p>
892892+ </footer>
893893+</body>
894894+</html>
895895+{{ end }}
896896+```
897897+898898+### Head Component (templates/components/head.html)
899899+900900+```html
901901+{{ define "head" }}
902902+<link rel="stylesheet" href="/admin/static/css/admin.css">
903903+<script src="https://unpkg.com/htmx.org@1.9.10"></script>
904904+<script src="https://unpkg.com/lucide@latest"></script>
905905+{{ end }}
906906+```
907907+908908+### Navigation (templates/components/nav.html)
909909+910910+```html
911911+{{ define "nav" }}
912912+<nav class="admin-nav">
913913+ <div class="nav-brand">
914914+ <a href="/admin">Hold Admin</a>
915915+ </div>
916916+ <ul class="nav-links">
917917+ <li><a href="/admin" class="{{ if eq .ActivePage "dashboard" }}active{{ end }}">Dashboard</a></li>
918918+ <li><a href="/admin/crew" class="{{ if eq .ActivePage "crew" }}active{{ end }}">Crew</a></li>
919919+ <li><a href="/admin/settings" class="{{ if eq .ActivePage "settings" }}active{{ end }}">Settings</a></li>
920920+ </ul>
921921+ <div class="nav-user">
922922+ <span>{{ .User.Handle }}</span>
923923+ <a href="/admin/auth/logout">Logout</a>
924924+ </div>
925925+</nav>
926926+{{ end }}
927927+```
928928+929929+### Dashboard Page (templates/pages/dashboard.html)
930930+931931+```html
932932+{{ define "dashboard" }}
933933+{{ template "base" . }}
934934+{{ define "content" }}
935935+<h1>Dashboard</h1>
936936+937937+<div class="stats-grid">
938938+ <div class="stat-card">
939939+ <h3>Crew Members</h3>
940940+ <p class="stat-value">{{ .Stats.TotalCrewMembers }}</p>
941941+ </div>
942942+943943+ <div class="stat-card" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML">
944944+ <p>Loading storage stats...</p>
945945+ </div>
946946+</div>
947947+948948+<section class="dashboard-section">
949949+ <h2>Tier Distribution</h2>
950950+ <div class="tier-chart">
951951+ {{ range $tier, $count := .Stats.TierDistribution }}
952952+ <div class="tier-bar">
953953+ <span class="tier-name">{{ $tier }}</span>
954954+ <span class="tier-count">{{ $count }}</span>
955955+ </div>
956956+ {{ end }}
957957+ </div>
958958+</section>
959959+960960+<section class="dashboard-section">
961961+ <h2>Top Users by Storage</h2>
962962+ <div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML">
963963+ <p>Loading top users...</p>
964964+ </div>
965965+</section>
966966+{{ end }}
967967+{{ end }}
968968+```
969969+970970+### Crew List Page (templates/pages/crew.html)
971971+972972+```html
973973+{{ define "crew" }}
974974+{{ template "base" . }}
975975+{{ define "content" }}
976976+<div class="page-header">
977977+ <h1>Crew Members</h1>
978978+ <a href="/admin/crew/add" class="btn btn-primary">Add Crew Member</a>
979979+</div>
980980+981981+<table class="data-table">
982982+ <thead>
983983+ <tr>
984984+ <th>DID</th>
985985+ <th>Role</th>
986986+ <th>Permissions</th>
987987+ <th>Tier</th>
988988+ <th>Usage</th>
989989+ <th>Actions</th>
990990+ </tr>
991991+ </thead>
992992+ <tbody id="crew-list">
993993+ {{ range .Crew }}
994994+ {{ template "crew_row" . }}
995995+ {{ end }}
996996+ </tbody>
997997+</table>
998998+{{ end }}
999999+{{ end }}
10001000+```
10011001+10021002+### Crew Row Partial (templates/partials/crew_row.html)
10031003+10041004+```html
10051005+{{ define "crew_row" }}
10061006+<tr id="crew-{{ .RKey }}">
10071007+ <td>
10081008+ <code title="{{ .DID }}">{{ .DID | truncate 20 }}</code>
10091009+ {{ if .Plankowner }}<span class="badge badge-gold">Plankowner</span>{{ end }}
10101010+ </td>
10111011+ <td>{{ .Role }}</td>
10121012+ <td>
10131013+ {{ range .Permissions }}
10141014+ <span class="badge badge-perm">{{ . }}</span>
10151015+ {{ end }}
10161016+ </td>
10171017+ <td>
10181018+ <span class="badge badge-tier tier-{{ .Tier }}">{{ .Tier }}</span>
10191019+ <small>({{ .TierLimit }})</small>
10201020+ </td>
10211021+ <td>
10221022+ <div class="usage-cell">
10231023+ <span>{{ .UsageHuman }}</span>
10241024+ <div class="progress-bar">
10251025+ <div class="progress-fill {{ if gt .UsagePercent 90 }}danger{{ else if gt .UsagePercent 75 }}warning{{ end }}"
10261026+ style="width: {{ .UsagePercent }}%"></div>
10271027+ </div>
10281028+ <small>{{ .UsagePercent }}%</small>
10291029+ </div>
10301030+ </td>
10311031+ <td>
10321032+ <a href="/admin/crew/{{ .RKey }}" class="btn btn-sm">Edit</a>
10331033+ <button class="btn btn-sm btn-danger"
10341034+ hx-post="/admin/crew/{{ .RKey }}/delete"
10351035+ hx-confirm="Are you sure you want to remove this crew member?"
10361036+ hx-target="#crew-{{ .RKey }}"
10371037+ hx-swap="outerHTML">
10381038+ Delete
10391039+ </button>
10401040+ </td>
10411041+</tr>
10421042+{{ end }}
10431043+```
10441044+10451045+### Settings Page (templates/pages/settings.html)
10461046+10471047+```html
10481048+{{ define "settings" }}
10491049+{{ template "base" . }}
10501050+{{ define "content" }}
10511051+<h1>Hold Settings</h1>
10521052+10531053+<form action="/admin/settings/update" method="POST" class="settings-form">
10541054+ <div class="setting-group">
10551055+ <h2>Access Control</h2>
10561056+10571057+ <label class="toggle-setting">
10581058+ <input type="checkbox" name="public" {{ if .Settings.Public }}checked{{ end }}>
10591059+ <span class="toggle-label">
10601060+ <strong>Public Hold</strong>
10611061+ <small>Allow anonymous users to read blobs (no auth required for pulls)</small>
10621062+ </span>
10631063+ </label>
10641064+10651065+ <label class="toggle-setting">
10661066+ <input type="checkbox" name="allow_all_crew" {{ if .Settings.AllowAllCrew }}checked{{ end }}>
10671067+ <span class="toggle-label">
10681068+ <strong>Open Registration</strong>
10691069+ <small>Allow any authenticated user to join as crew via requestCrew</small>
10701070+ </span>
10711071+ </label>
10721072+ </div>
10731073+10741074+ <div class="setting-group">
10751075+ <h2>Integrations</h2>
10761076+10771077+ <label class="toggle-setting">
10781078+ <input type="checkbox" name="enable_bluesky_posts" {{ if .Settings.EnableBlueskyPosts }}checked{{ end }}>
10791079+ <span class="toggle-label">
10801080+ <strong>Bluesky Posts</strong>
10811081+ <small>Post to Bluesky when images are pushed to this hold</small>
10821082+ </span>
10831083+ </label>
10841084+ </div>
10851085+10861086+ <div class="setting-group">
10871087+ <h2>Hold Information</h2>
10881088+ <dl>
10891089+ <dt>Hold DID</dt>
10901090+ <dd><code>{{ .Settings.HoldDID }}</code></dd>
10911091+ <dt>Owner DID</dt>
10921092+ <dd><code>{{ .Settings.OwnerDID }}</code></dd>
10931093+ </dl>
10941094+ </div>
10951095+10961096+ <button type="submit" class="btn btn-primary">Save Settings</button>
10971097+</form>
10981098+{{ end }}
10991099+{{ end }}
11001100+```
11011101+11021102+---
11031103+11041104+## Environment Variables
11051105+11061106+Add to `.env.hold.example`:
11071107+11081108+```bash
11091109+# =============================================================================
11101110+# ADMIN PANEL CONFIGURATION
11111111+# =============================================================================
11121112+11131113+# Enable the admin web UI (default: false)
11141114+# When enabled, accessible at /admin
11151115+HOLD_ADMIN_ENABLED=false
11161116+11171117+# Admin session duration (default: 24h)
11181118+# How long admin sessions remain valid before requiring re-authentication
11191119+# Format: Go duration string (e.g., 24h, 168h for 1 week)
11201120+HOLD_ADMIN_SESSION_DURATION=24h
11211121+```
11221122+11231123+### Config Struct Updates
11241124+11251125+```go
11261126+// In pkg/hold/config.go
11271127+11281128+type Config struct {
11291129+ // ... existing fields ...
11301130+11311131+ Admin AdminConfig
11321132+}
11331133+11341134+type AdminConfig struct {
11351135+ Enabled bool `env:"HOLD_ADMIN_ENABLED" envDefault:"false"`
11361136+ SessionDuration time.Duration `env:"HOLD_ADMIN_SESSION_DURATION" envDefault:"24h"`
11371137+}
11381138+```
11391139+11401140+---
11411141+11421142+## Security Considerations
11431143+11441144+### 1. Owner-Only Access
11451145+11461146+All admin routes validate that the authenticated user's DID matches `captain.Owner`. This check happens:
11471147+- In the OAuth callback (primary gate)
11481148+- In the `requireOwner` middleware (defense in depth)
11491149+- Before destructive operations (extra validation)
11501150+11511151+### 2. Cookie Security
11521152+11531153+```go
11541154+http.SetCookie(w, &http.Cookie{
11551155+ Name: "hold_admin_session",
11561156+ Value: sessionID,
11571157+ Path: "/admin", // Scoped to admin paths only
11581158+ MaxAge: 86400, // 24 hours
11591159+ HttpOnly: true, // No JavaScript access
11601160+ Secure: isHTTPS(r), // HTTPS only in production
11611161+ SameSite: http.SameSiteLaxMode, // CSRF protection
11621162+})
11631163+```
11641164+11651165+### 3. CSRF Protection
11661166+11671167+For state-changing operations:
11681168+- Forms include hidden CSRF token
11691169+- HTMX requests include token in header
11701170+- Server validates token before processing
11711171+11721172+```html
11731173+<form action="/admin/crew/add" method="POST">
11741174+ <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
11751175+ ...
11761176+</form>
11771177+```
11781178+11791179+### 4. Input Validation
11801180+11811181+- DID format validation before database operations
11821182+- Tier names validated against `quotas.yaml`
11831183+- Permission values validated against known set
11841184+- All user input sanitized before display
11851185+11861186+### 5. Rate Limiting
11871187+11881188+Consider adding rate limiting for:
11891189+- Login attempts (prevent brute force)
11901190+- OAuth flow starts (prevent abuse)
11911191+- API endpoints (prevent DoS)
11921192+11931193+### 6. Audit Logging
11941194+11951195+Log all administrative actions:
11961196+```go
11971197+slog.Info("Admin action",
11981198+ "action", "crew_add",
11991199+ "admin_did", session.DID,
12001200+ "target_did", newMemberDID,
12011201+ "permissions", permissions)
12021202+```
12031203+12041204+---
12051205+12061206+## Implementation Phases
12071207+12081208+### Phase 1: Foundation (Est. 4-6 hours)
12091209+12101210+1. Create `pkg/hold/admin/` package structure
12111211+2. Implement `AdminSessionStore` with SQLite
12121212+3. Implement OAuth client setup (reuse `pkg/auth/oauth/`)
12131213+4. Implement `requireOwner` middleware
12141214+5. Create basic template loading with embed.FS
12151215+6. Add env var configuration to `pkg/hold/config.go`
12161216+12171217+**Deliverables:**
12181218+- Admin package compiles
12191219+- Can start OAuth flow
12201220+- Session store creates/validates sessions
12211221+12221222+### Phase 2: Authentication (Est. 3-4 hours)
12231223+12241224+1. Implement login page handler
12251225+2. Implement OAuth authorize redirect
12261226+3. Implement callback with owner validation
12271227+4. Implement logout handler
12281228+5. Wire up routes in `cmd/hold/main.go`
12291229+12301230+**Deliverables:**
12311231+- Can login as hold owner
12321232+- Non-owners rejected at callback
12331233+- Sessions persist across requests
12341234+12351235+### Phase 3: Dashboard (Est. 3-4 hours)
12361236+12371237+1. Create base template and navigation
12381238+2. Implement dashboard handler with basic stats
12391239+3. Implement stats API for HTMX lazy loading
12401240+4. Implement top users API
12411241+5. Create dashboard template
12421242+12431243+**Deliverables:**
12441244+- Dashboard shows crew count, tier distribution
12451245+- Storage stats load asynchronously
12461246+- Top users table displays
12471247+12481248+### Phase 4: Crew Management (Est. 4-6 hours)
12491249+12501250+1. Implement crew list handler
12511251+2. Create crew list template with HTMX delete
12521252+3. Implement add crew form and handler
12531253+4. Implement edit crew form and handler
12541254+5. Implement delete crew handler
12551255+12561256+**Deliverables:**
12571257+- Full CRUD for crew members
12581258+- Tier and permission editing works
12591259+- HTMX updates without page reload
12601260+12611261+### Phase 5: Settings (Est. 2-3 hours)
12621262+12631263+1. Implement settings handler
12641264+2. Create settings template
12651265+3. Implement settings update handler
12661266+12671267+**Deliverables:**
12681268+- Can toggle public/allowAllCrew/enableBlueskyPosts
12691269+- Settings persist correctly
12701270+12711271+### Phase 6: Polish (Est. 2-4 hours)
12721272+12731273+1. Add CSS styling
12741274+2. Add flash messages
12751275+3. Add CSRF protection
12761276+4. Add input validation
12771277+5. Add audit logging
12781278+6. Update documentation
12791279+12801280+**Deliverables:**
12811281+- Professional-looking UI
12821282+- Security hardening complete
12831283+- Documentation updated
12841284+12851285+**Total Estimated Time: 18-27 hours**
12861286+12871287+---
12881288+12891289+## Testing Strategy
12901290+12911291+### Unit Tests
12921292+12931293+```go
12941294+// pkg/hold/admin/session_test.go
12951295+func TestSessionStore_Create(t *testing.T) {
12961296+ store := newTestSessionStore(t)
12971297+12981298+ sessionID, err := store.Create("did:plc:test", "test.handle", 24*time.Hour)
12991299+ require.NoError(t, err)
13001300+ require.NotEmpty(t, sessionID)
13011301+13021302+ session, err := store.Get(sessionID)
13031303+ require.NoError(t, err)
13041304+ assert.Equal(t, "did:plc:test", session.DID)
13051305+}
13061306+13071307+// pkg/hold/admin/auth_test.go
13081308+func TestRequireOwner_RejectsNonOwner(t *testing.T) {
13091309+ pds := setupTestPDSWithOwner(t, "did:plc:owner")
13101310+ store := newTestSessionStore(t)
13111311+13121312+ // Create session for non-owner
13131313+ sessionID, _ := store.Create("did:plc:notowner", "notowner", 24*time.Hour)
13141314+13151315+ middleware := requireOwner(pds, store)
13161316+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13171317+ w.WriteHeader(http.StatusOK)
13181318+ }))
13191319+13201320+ req := httptest.NewRequest("GET", "/admin", nil)
13211321+ req.AddCookie(&http.Cookie{Name: "hold_admin_session", Value: sessionID})
13221322+ w := httptest.NewRecorder()
13231323+13241324+ handler.ServeHTTP(w, req)
13251325+13261326+ assert.Equal(t, http.StatusForbidden, w.Code)
13271327+}
13281328+```
13291329+13301330+### Integration Tests
13311331+13321332+```go
13331333+// pkg/hold/admin/integration_test.go
13341334+func TestAdminLoginFlow(t *testing.T) {
13351335+ // Start test hold server
13361336+ server := startTestHoldWithAdmin(t)
13371337+ defer server.Close()
13381338+13391339+ // Verify login page accessible
13401340+ resp, _ := http.Get(server.URL + "/admin/auth/login")
13411341+ assert.Equal(t, http.StatusOK, resp.StatusCode)
13421342+13431343+ // Verify dashboard redirects to login
13441344+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
13451345+ return http.ErrUseLastResponse
13461346+ }}
13471347+ resp, _ = client.Get(server.URL + "/admin")
13481348+ assert.Equal(t, http.StatusFound, resp.StatusCode)
13491349+ assert.Contains(t, resp.Header.Get("Location"), "/admin/auth/login")
13501350+}
13511351+```
13521352+13531353+### Manual Testing Checklist
13541354+13551355+- [ ] Login as owner succeeds
13561356+- [ ] Login as non-owner fails with clear error
13571357+- [ ] Dashboard loads with correct stats
13581358+- [ ] Add crew member with all permission combinations
13591359+- [ ] Edit crew member permissions
13601360+- [ ] Change crew member tier
13611361+- [ ] Delete crew member
13621362+- [ ] Toggle public setting
13631363+- [ ] Toggle allowAllCrew setting
13641364+- [ ] Toggle enableBlueskyPosts setting
13651365+- [ ] Logout clears session
13661366+- [ ] Session expires after configured duration
13671367+- [ ] Expired session redirects to login
13681368+13691369+---
13701370+13711371+## Future Enhancements
13721372+13731373+### Potential Future Features
13741374+13751375+1. **Crew Invite Links** - Generate one-time invite URLs for adding crew
13761376+2. **Usage Alerts** - Email/webhook when users approach quota
13771377+3. **Bulk Operations** - Add/remove multiple crew members at once
13781378+4. **Export Data** - Download crew list, usage reports as CSV
13791379+5. **Activity Log** - View recent admin actions
13801380+6. **API Keys** - Generate programmatic access keys for admin API
13811381+7. **Backup/Restore** - Backup crew records, restore from backup
13821382+8. **Multi-Hold Management** - Manage multiple holds from one UI (separate feature)
13831383+13841384+### Performance Optimizations
13851385+13861386+1. **Cache usage stats** - Don't recalculate on every request
13871387+2. **Paginate crew list** - Handle holds with 1000+ crew members
13881388+3. **Background stat refresh** - Update stats periodically in background
13891389+4. **Batch DID resolution** - Resolve multiple DIDs in parallel
13901390+13911391+---
13921392+13931393+## References
13941394+13951395+- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
13961396+- [DPoP RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449)
13971397+- [HTMX Documentation](https://htmx.org/docs/)
13981398+- [Chi Router](https://github.com/go-chi/chi)
13991399+- [Go html/template](https://pkg.go.dev/html/template)
+10-28
pkg/appview/db/oauth_store.go
···88 "log/slog"
99 "time"
10101111+ atoauth "atcr.io/pkg/auth/oauth"
1112 "github.com/bluesky-social/indigo/atproto/auth/oauth"
1213 "github.com/bluesky-social/indigo/atproto/syntax"
1314)
···283284 continue
284285 }
285286286286- // Check if scopes match (need to import oauth package for ScopesMatch)
287287- // Since we're in db package, we can't import oauth (circular dependency)
288288- // So we'll implement a simple scope comparison here
289289- if !scopesMatch(sessionData.Scopes, desiredScopes) {
287287+ // Check if scopes match (expands include: scopes before comparing)
288288+ if !atoauth.ScopesMatch(sessionData.Scopes, desiredScopes) {
289289+ slog.Debug("Session has mismatched scopes",
290290+ "component", "oauth/store",
291291+ "session_key", sessionKey,
292292+ "account_did", accountDID,
293293+ "session_scopes", sessionData.Scopes,
294294+ "desired_scopes", desiredScopes,
295295+ )
290296 sessionsToDelete = append(sessionsToDelete, sessionKey)
291297 }
292298 }
···309315 }
310316311317 return len(sessionsToDelete), nil
312312-}
313313-314314-// scopesMatch checks if two scope lists are equivalent (order-independent)
315315-// Local implementation to avoid circular dependency with oauth package
316316-func scopesMatch(stored, desired []string) bool {
317317- if len(stored) == 0 && len(desired) == 0 {
318318- return true
319319- }
320320- if len(stored) != len(desired) {
321321- return false
322322- }
323323-324324- desiredMap := make(map[string]bool, len(desired))
325325- for _, scope := range desired {
326326- desiredMap[scope] = true
327327- }
328328-329329- for _, scope := range stored {
330330- if !desiredMap[scope] {
331331- return false
332332- }
333333- }
334334-335335- return true
336318}
337319338320// GetSessionStats returns statistics about stored OAuth sessions
+16-3
pkg/appview/db/oauth_store_test.go
···55 "testing"
66 "time"
7788+ atcroauth "atcr.io/pkg/auth/oauth"
89 "github.com/bluesky-social/indigo/atproto/auth/oauth"
910 "github.com/bluesky-social/indigo/atproto/syntax"
1011)
···161162}
162163163164func TestScopesMatch(t *testing.T) {
164164- // Test the local scopesMatch function to ensure it matches the oauth.ScopesMatch behavior
165165+ // Test oauth.ScopesMatch function including include: scope expansion
165166 tests := []struct {
166167 name string
167168 stored []string
···204205 desired: []string{},
205206 expected: true,
206207 },
208208+ {
209209+ name: "include scope expansion",
210210+ stored: []string{
211211+ "atproto",
212212+ "repo?collection=io.atcr.manifest&collection=io.atcr.repo.page&collection=io.atcr.sailor.profile&collection=io.atcr.sailor.star&collection=io.atcr.tag",
213213+ },
214214+ desired: []string{
215215+ "atproto",
216216+ "include:io.atcr.authFullApp",
217217+ },
218218+ expected: true,
219219+ },
207220 }
208221209222 for _, tt := range tests {
210223 t.Run(tt.name, func(t *testing.T) {
211211- result := scopesMatch(tt.stored, tt.desired)
224224+ result := atcroauth.ScopesMatch(tt.stored, tt.desired)
212225 if result != tt.expected {
213213- t.Errorf("scopesMatch(%v, %v) = %v, want %v",
226226+ t.Errorf("ScopesMatch(%v, %v) = %v, want %v",
214227 tt.stored, tt.desired, result, tt.expected)
215228 }
216229 })
+42-5
pkg/auth/oauth/client.go
···1717 "github.com/bluesky-social/indigo/atproto/syntax"
1818)
19192020+// permissionSetExpansions maps lexicon IDs to their expanded scope format.
2121+// These must match the collections defined in lexicons/io/atcr/authFullApp.json
2222+// Collections are sorted alphabetically for consistent comparison with PDS-expanded scopes.
2323+var permissionSetExpansions = map[string]string{
2424+ "io.atcr.authFullApp": "repo?" +
2525+ "collection=io.atcr.manifest&" +
2626+ "collection=io.atcr.repo.page&" +
2727+ "collection=io.atcr.sailor.profile&" +
2828+ "collection=io.atcr.sailor.star&" +
2929+ "collection=io.atcr.tag",
3030+}
3131+3232+// ExpandIncludeScopes expands any "include:" prefixed scopes to their full form
3333+// by looking up the corresponding permission-set in the embedded lexicon files.
3434+// For example, "include:io.atcr.authFullApp" expands to "repo?collection=io.atcr.manifest&..."
3535+func ExpandIncludeScopes(scopes []string) []string {
3636+ var expanded []string
3737+ for _, scope := range scopes {
3838+ if strings.HasPrefix(scope, "include:") {
3939+ lexiconID := strings.TrimPrefix(scope, "include:")
4040+ if exp, ok := permissionSetExpansions[lexiconID]; ok {
4141+ expanded = append(expanded, exp)
4242+ } else {
4343+ expanded = append(expanded, scope) // Keep original if unknown
4444+ }
4545+ } else {
4646+ expanded = append(expanded, scope)
4747+ }
4848+ }
4949+ return expanded
5050+}
5151+2052// NewClientApp creates an indigo OAuth ClientApp with ATCR-specific configuration
2153// Automatically configures confidential client for production deployments
2254// keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost)
···97129}
9813099131// ScopesMatch checks if two scope lists are equivalent (order-independent)
100100-// Returns true if both lists contain the same scopes, regardless of order
132132+// Returns true if both lists contain the same scopes, regardless of order.
133133+// Expands any "include:" prefixed scopes in the desired list before comparing,
134134+// since the PDS returns expanded scopes in the stored session.
101135func ScopesMatch(stored, desired []string) bool {
136136+ // Expand any include: scopes in desired before comparing
137137+ expandedDesired := ExpandIncludeScopes(desired)
138138+102139 // Handle nil/empty cases
103103- if len(stored) == 0 && len(desired) == 0 {
140140+ if len(stored) == 0 && len(expandedDesired) == 0 {
104141 return true
105142 }
106106- if len(stored) != len(desired) {
143143+ if len(stored) != len(expandedDesired) {
107144 return false
108145 }
109146110147 // Build map of desired scopes for O(1) lookup
111111- desiredMap := make(map[string]bool, len(desired))
112112- for _, scope := range desired {
148148+ desiredMap := make(map[string]bool, len(expandedDesired))
149149+ for _, scope := range expandedDesired {
113150 desiredMap[scope] = true
114151 }
115152