From f35bf2bcdea76171b8b95ae2bf654d25e53549f3 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Mon, 5 Jan 2026 20:26:41 -0600 Subject: [PATCH] fix oauth scope mismatch --- .air.hold.toml | 25 + .gitignore | 1 + Dockerfile.dev | 10 +- cmd/appview/serve.go | 9 +- cmd/usage-report/main.go | 2 +- docker-compose.yml | 10 +- docs/ADMIN_PANEL.md | 1399 ++++++++++++++++++++++++++++ pkg/appview/db/oauth_store.go | 38 +- pkg/appview/db/oauth_store_test.go | 19 +- pkg/auth/oauth/client.go | 47 +- 10 files changed, 1510 insertions(+), 50 deletions(-) create mode 100644 .air.hold.toml create mode 100644 docs/ADMIN_PANEL.md diff --git a/.air.hold.toml b/.air.hold.toml new file mode 100644 index 0000000..a5af303 --- /dev/null +++ b/.air.hold.toml @@ -0,0 +1,25 @@ +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -buildvcs=false -o ./tmp/atcr-hold ./cmd/hold" +entrypoint = ["./tmp/atcr-hold"] +include_ext = ["go"] +exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview"] +exclude_regex = ["_test\\.go$"] +delay = 1000 +stop_on_error = true +send_interrupt = true +kill_delay = 500 + +[log] +time = false + +[color] +main = "blue" +watcher = "magenta" +build = "yellow" +runner = "green" + +[misc] +clean_on_exit = true diff --git a/.gitignore b/.gitignore index 1c0e539..23c8aff 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ pkg/appview/static/js/htmx.min.js pkg/appview/static/js/lucide.min.js # IDE +.zed/ .claude/ .vscode/ .idea/ diff --git a/Dockerfile.dev b/Dockerfile.dev index 63fd979..96bab16 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,9 +1,12 @@ # Development image with Air hot reload -# Build: docker build -f Dockerfile.dev -t atcr-appview-dev . -# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev +# Build: docker build -f Dockerfile.dev -t atcr-dev . +# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-dev FROM docker.io/golang:1.25.4-trixie +ARG AIR_CONFIG=.air.toml + ENV DEBIAN_FRONTEND=noninteractive +ENV AIR_CONFIG=${AIR_CONFIG} RUN apt-get update && \ apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl && \ @@ -17,5 +20,4 @@ COPY go.mod go.sum ./ RUN go mod download # For development: source mounted as volume, Air handles builds -EXPOSE 5000 -CMD ["air", "-c", ".air.toml"] +CMD ["sh", "-c", "air -c ${AIR_CONFIG}"] diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 68fa0c8..71e6245 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -114,7 +114,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error { slog.Debug("Base URL for OAuth", "base_url", baseURL) if testMode { - slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope") + slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution") } // Create OAuth client app (automatically configures confidential client for production) @@ -123,11 +123,6 @@ func serveRegistry(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to create OAuth client app: %w", err) } - if testMode { - slog.Info("Using OAuth scopes with transition:generic (test mode)") - } else { - slog.Info("Using OAuth scopes with RPC scope (production mode)") - } // Invalidate sessions with mismatched scopes on startup // This ensures all users have the latest required scopes after deployment @@ -383,7 +378,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error { logoURI := cfg.Server.BaseURL + "/web-app-manifest-192x192.png" policyURI := cfg.Server.BaseURL + "/privacy" tosURI := cfg.Server.BaseURL + "/terms" - + metadata := config.ClientMetadata() metadata.ClientName = &cfg.Server.ClientName metadata.ClientURI = &cfg.Server.BaseURL diff --git a/cmd/usage-report/main.go b/cmd/usage-report/main.go index f2dcb87..94a2e9a 100644 --- a/cmd/usage-report/main.go +++ b/cmd/usage-report/main.go @@ -110,7 +110,7 @@ func main() { fmt.Println("=== Calculating from hold layer records ===") fmt.Println("NOTE: May undercount app-password users due to layer record bug") fmt.Println(" Use --from-manifests for more accurate results") - + userUsage, err = calculateFromLayerRecords(baseURL, holdDID) } diff --git a/docker-compose.yml b/docker-compose.yml index d544a91..57a592a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,12 +57,18 @@ services: # Storage config comes from env_file (STORAGE_DRIVER, AWS_*, S3_*) build: context: . - dockerfile: Dockerfile.hold - image: atcr-hold:latest + dockerfile: Dockerfile.dev + args: + AIR_CONFIG: .air.hold.toml + image: atcr-hold-dev:latest container_name: atcr-hold ports: - "8080:8080" volumes: + # Mount source code for Air hot reload + - .:/app + # Cache go modules between rebuilds + - go-mod-cache:/go/pkg/mod # PDS data (carstore SQLite + signing keys) - atcr-hold:/var/lib/atcr-hold restart: unless-stopped diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md new file mode 100644 index 0000000..040564f --- /dev/null +++ b/docs/ADMIN_PANEL.md @@ -0,0 +1,1399 @@ +# Hold Admin Panel Implementation Plan + +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. + +## Table of Contents + +1. [Overview](#overview) +2. [Requirements](#requirements) +3. [Architecture](#architecture) +4. [File Structure](#file-structure) +5. [Authentication](#authentication) +6. [Session Management](#session-management) +7. [Route Structure](#route-structure) +8. [Feature Implementations](#feature-implementations) +9. [Templates](#templates) +10. [Environment Variables](#environment-variables) +11. [Security Considerations](#security-considerations) +12. [Implementation Phases](#implementation-phases) +13. [Testing Strategy](#testing-strategy) + +--- + +## Overview + +The hold admin panel provides a web-based interface for hold owners to: + +- **Manage crew members**: Add, remove, edit permissions and quota tiers +- **Configure hold settings**: Toggle public access, open registration, Bluesky posting +- **View usage metrics**: Storage usage per user, top users, repository statistics +- **Monitor quota utilization**: Track tier distribution and usage percentages + +The admin panel is owner-only - only the DID that matches `captain.Owner` can access it. + +--- + +## Requirements + +### Functional Requirements + +1. **Crew Management** + - List all crew members with their DID, role, permissions, tier, and storage usage + - Add new crew members with specified permissions and tier + - Edit existing crew member permissions and tier + - Remove crew members (with confirmation) + - Display each crew member's quota utilization percentage + +2. **Quota/Tier Management** + - Display available tiers from `quotas.yaml` + - Show tier limits and descriptions + - Allow changing crew member tiers + - Display current vs limit usage for each user + +3. **Usage Metrics** + - Total storage used across all users + - Total unique blobs (deduplicated) + - Number of crew members + - Top 10/50/100 users by storage consumption + - Per-repository statistics (pulls, pushes) + +4. **Hold Settings** + - Toggle `public` (allow anonymous blob reads) + - Toggle `allowAllCrew` (allow any authenticated user to join) + - Toggle `enableBlueskyPosts` (post to Bluesky on image push) + +### Non-Functional Requirements + +- **Single binary**: Embedded in hold service, no separate deployment +- **Responsive UI**: Works on desktop and mobile browsers +- **Low latency**: Dashboard loads in <500ms for typical data volumes +- **Minimal dependencies**: Uses Go templates, HTMX for interactivity + +--- + +## Architecture + +### High-Level Design + +``` +┌─────────────────────────────────────────────────────────┐ +│ Hold Service │ +├─────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ +│ │ XRPC/PDS │ │ OCI XRPC │ │ Admin Panel │ │ +│ │ Handlers │ │ Handlers │ │ Handlers │ │ +│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ ┌──────┴────────────────┴───────────────────┴────────┐ │ +│ │ Chi Router │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────┴─────────────────────────┐ │ +│ │ Embedded PDS │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ Captain │ │ Crew │ │ Layer │ │ │ +│ │ │ Records │ │ Records │ │ Records │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └───────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Components + +1. **AdminUI** - Main struct containing all admin dependencies +2. **Session Store** - SQLite-backed session management (separate from carstore) +3. **OAuth Client** - Reuses `pkg/auth/oauth/` for browser-based login +4. **Auth Middleware** - Validates owner-only access +5. **Handlers** - HTTP handlers for each admin page +6. **Templates** - Go html/template with embed.FS + +--- + +## File Structure + +``` +pkg/hold/admin/ +├── admin.go # Main struct, initialization, route registration +├── auth.go # requireOwner middleware, session validation +├── handlers.go # HTTP handlers for all admin pages +├── session.go # SQLite session store implementation +├── metrics.go # Metrics collection and aggregation +├── templates/ +│ ├── base.html # Base layout (html, head, body wrapper) +│ ├── components/ +│ │ ├── head.html # CSS/JS includes (HTMX, Lucide icons) +│ │ ├── nav.html # Admin navigation bar +│ │ └── flash.html # Flash message component +│ ├── pages/ +│ │ ├── login.html # OAuth login page +│ │ ├── dashboard.html # Metrics overview +│ │ ├── crew.html # Crew list with management actions +│ │ ├── crew_add.html # Add crew member form +│ │ ├── crew_edit.html # Edit crew member form +│ │ └── settings.html # Hold settings page +│ └── partials/ +│ ├── crew_row.html # Single crew row (for HTMX updates) +│ ├── usage_stats.html # Usage stats partial +│ └── top_users.html # Top users table partial +└── static/ + ├── css/ + │ └── admin.css # Admin-specific styles + └── js/ + └── admin.js # Admin-specific JavaScript (if needed) +``` + +### Files to Modify + +| File | Changes | +|------|---------| +| `cmd/hold/main.go` | Add admin UI initialization and route registration | +| `pkg/hold/config.go` | Add `Admin.Enabled` and `Admin.SessionDuration` fields | +| `.env.hold.example` | Document `HOLD_ADMIN_ENABLED`, `HOLD_ADMIN_SESSION_DURATION` | + +--- + +## Authentication + +### OAuth Flow for Admin Login + +The admin panel uses ATProto OAuth with DPoP for browser-based authentication: + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Browser │ │ Hold │ │ PDS │ │ Owner │ +│ │ │ Admin │ │ │ │ │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ + │ GET /admin │ │ │ + │───────────────>│ │ │ + │ │ │ │ + │ 302 /admin/auth/login │ │ + │<───────────────│ │ │ + │ │ │ │ + │ GET /admin/auth/login │ │ + │───────────────>│ │ │ + │ │ │ │ + │ Login page (enter handle) │ │ + │<───────────────│ │ │ + │ │ │ │ + │ POST handle │ │ │ + │───────────────>│ │ │ + │ │ │ │ + │ │ StartAuthFlow │ │ + │ │───────────────>│ │ + │ │ │ │ + │ 302 to PDS auth URL │ │ + │<───────────────│ │ │ + │ │ │ │ + │ Authorize in browser │ │ + │────────────────────────────────>│ │ + │ │ │ Approve? │ + │ │ │───────────────>│ + │ │ │ │ + │ │ │ Yes │ + │ │ │<───────────────│ + │ │ │ │ + │ 302 callback with code │ │ + │<────────────────────────────────│ │ + │ │ │ │ + │ GET /admin/auth/oauth/callback │ │ + │───────────────>│ │ │ + │ │ │ │ + │ │ ProcessCallback│ │ + │ │───────────────>│ │ + │ │ │ │ + │ │ OAuth tokens │ │ + │ │<───────────────│ │ + │ │ │ │ + │ │ Check: DID == captain.Owner? │ + │ │─────────────────────────────────│ + │ │ │ │ + │ │ YES: Create session │ + │ │ │ │ + │ 302 /admin + session cookie │ │ + │<───────────────│ │ │ + │ │ │ │ + │ GET /admin (with cookie) │ │ + │───────────────>│ │ │ + │ │ │ │ + │ Dashboard │ │ │ + │<───────────────│ │ │ +``` + +### Owner Validation + +The callback handler performs owner validation: + +```go +func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) { + // Process OAuth callback + sessionData, err := ui.clientApp.ProcessCallback(r.Context(), r.URL.Query()) + if err != nil { + ui.renderError(w, "OAuth failed: " + err.Error()) + return + } + + did := sessionData.AccountDID.String() + + // Get captain record to check owner + _, captain, err := ui.pds.GetCaptainRecord(r.Context()) + if err != nil { + ui.renderError(w, "Failed to verify ownership") + return + } + + // CRITICAL: Only allow the hold owner + if did != captain.Owner { + slog.Warn("Non-owner attempted admin access", "did", did, "owner", captain.Owner) + ui.renderError(w, "Access denied: Only the hold owner can access the admin panel") + return + } + + // Create admin session + sessionID, err := ui.sessionStore.Create(did, sessionData.Handle, 24*time.Hour) + if err != nil { + ui.renderError(w, "Failed to create session") + return + } + + // Set session cookie + http.SetCookie(w, &http.Cookie{ + Name: "hold_admin_session", + Value: sessionID, + Path: "/admin", + MaxAge: 86400, // 24 hours + HttpOnly: true, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, "/admin", http.StatusFound) +} +``` + +### Auth Middleware + +```go +// requireOwner ensures the request is from the hold owner +func (ui *AdminUI) requireOwner(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get session cookie + cookie, err := r.Cookie("hold_admin_session") + if err != nil { + http.Redirect(w, r, "/admin/auth/login?return_to="+r.URL.Path, http.StatusFound) + return + } + + // Validate session + session, err := ui.sessionStore.Get(cookie.Value) + if err != nil || session == nil || session.ExpiresAt.Before(time.Now()) { + // Clear invalid cookie + http.SetCookie(w, &http.Cookie{ + Name: "hold_admin_session", + Value: "", + Path: "/admin", + MaxAge: -1, + }) + http.Redirect(w, r, "/admin/auth/login", http.StatusFound) + return + } + + // Double-check DID still matches captain.Owner + // (in case ownership transferred while session active) + _, captain, err := ui.pds.GetCaptainRecord(r.Context()) + if err != nil || session.DID != captain.Owner { + ui.sessionStore.Delete(cookie.Value) + http.Error(w, "Access denied: ownership verification failed", http.StatusForbidden) + return + } + + // Add session to context for handlers + ctx := context.WithValue(r.Context(), adminSessionKey, session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} +``` + +--- + +## Session Management + +### Session Store Schema + +```sql +-- Admin sessions (browser login state) +CREATE TABLE IF NOT EXISTS admin_sessions ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL, + handle TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + last_accessed DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Index for cleanup queries +CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_admin_sessions_did ON admin_sessions(did); + +-- OAuth sessions (indigo library storage) +CREATE TABLE IF NOT EXISTS admin_oauth_sessions ( + session_id TEXT PRIMARY KEY, + did TEXT NOT NULL, + data BLOB NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +### Session Store Interface + +```go +// AdminSession represents an authenticated admin session +type AdminSession struct { + ID string + DID string + Handle string + CreatedAt time.Time + ExpiresAt time.Time + LastAccessed time.Time +} + +// AdminSessionStore manages admin sessions +type AdminSessionStore struct { + db *sql.DB +} + +func NewAdminSessionStore(dbPath string) (*AdminSessionStore, error) + +func (s *AdminSessionStore) Create(did, handle string, duration time.Duration) (string, error) +func (s *AdminSessionStore) Get(sessionID string) (*AdminSession, error) +func (s *AdminSessionStore) Delete(sessionID string) error +func (s *AdminSessionStore) DeleteForDID(did string) error +func (s *AdminSessionStore) Cleanup() error // Remove expired sessions +func (s *AdminSessionStore) Touch(sessionID string) error // Update last_accessed +``` + +### Database Location + +The admin database should be in the same directory as the carstore database: + +```go +adminDBPath := filepath.Join(cfg.Database.Path, "admin.db") +``` + +This keeps all hold data together while maintaining separation between the carstore (ATProto records) and admin sessions. + +--- + +## Route Structure + +### Complete Route Table + +| Route | Method | Auth | Handler | Description | +|-------|--------|------|---------|-------------| +| `/admin` | GET | Owner | `DashboardHandler` | Main dashboard with metrics | +| `/admin/crew` | GET | Owner | `CrewListHandler` | List all crew members | +| `/admin/crew/add` | GET | Owner | `CrewAddFormHandler` | Add crew form | +| `/admin/crew/add` | POST | Owner | `CrewAddHandler` | Process add crew | +| `/admin/crew/{rkey}` | GET | Owner | `CrewEditFormHandler` | Edit crew form | +| `/admin/crew/{rkey}/update` | POST | Owner | `CrewUpdateHandler` | Process crew update | +| `/admin/crew/{rkey}/delete` | POST | Owner | `CrewDeleteHandler` | Delete crew member | +| `/admin/settings` | GET | Owner | `SettingsHandler` | Hold settings page | +| `/admin/settings/update` | POST | Owner | `SettingsUpdateHandler` | Update settings | +| `/admin/api/stats` | GET | Owner | `StatsAPIHandler` | JSON stats endpoint | +| `/admin/api/top-users` | GET | Owner | `TopUsersAPIHandler` | JSON top users | +| `/admin/auth/login` | GET | Public | `LoginHandler` | Login page | +| `/admin/auth/oauth/authorize` | GET | Public | OAuth authorize | Start OAuth flow | +| `/admin/auth/oauth/callback` | GET | Public | `CallbackHandler` | OAuth callback | +| `/admin/auth/logout` | GET | Owner | `LogoutHandler` | Logout and clear session | +| `/admin/static/*` | GET | Public | Static files | CSS, JS assets | + +### Route Registration + +```go +func (ui *AdminUI) RegisterRoutes(r chi.Router) { + // Public routes (login flow) + r.Get("/admin/auth/login", ui.handleLogin) + r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize) + r.Get("/admin/auth/oauth/callback", ui.handleCallback) + + // Static files (public) + r.Handle("/admin/static/*", http.StripPrefix("/admin/static/", ui.staticHandler())) + + // Protected routes (require owner) + r.Group(func(r chi.Router) { + r.Use(ui.requireOwner) + + // Dashboard + r.Get("/admin", ui.handleDashboard) + + // Crew management + r.Get("/admin/crew", ui.handleCrewList) + r.Get("/admin/crew/add", ui.handleCrewAddForm) + r.Post("/admin/crew/add", ui.handleCrewAdd) + r.Get("/admin/crew/{rkey}", ui.handleCrewEditForm) + r.Post("/admin/crew/{rkey}/update", ui.handleCrewUpdate) + r.Post("/admin/crew/{rkey}/delete", ui.handleCrewDelete) + + // Settings + r.Get("/admin/settings", ui.handleSettings) + r.Post("/admin/settings/update", ui.handleSettingsUpdate) + + // API endpoints (for HTMX) + r.Get("/admin/api/stats", ui.handleStatsAPI) + r.Get("/admin/api/top-users", ui.handleTopUsersAPI) + + // Logout + r.Get("/admin/auth/logout", ui.handleLogout) + }) +} +``` + +--- + +## Feature Implementations + +### Dashboard Handler + +```go +type DashboardStats struct { + TotalCrewMembers int + TotalBlobs int64 + TotalStorageBytes int64 + TotalStorageHuman string + TierDistribution map[string]int // tier -> count + RecentActivity []ActivityEntry +} + +func (ui *AdminUI) handleDashboard(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Collect basic stats + crew, _ := ui.pds.ListCrewMembers(ctx) + + stats := DashboardStats{ + TotalCrewMembers: len(crew), + TierDistribution: make(map[string]int), + } + + // Count tier distribution + for _, member := range crew { + tier := member.Tier + if tier == "" { + tier = ui.quotaMgr.GetDefaultTier() + } + stats.TierDistribution[tier]++ + } + + // Storage stats (loaded via HTMX to avoid slow initial load) + // The actual calculation happens in handleStatsAPI + + data := struct { + AdminPageData + Stats DashboardStats + }{ + AdminPageData: ui.newPageData(r), + Stats: stats, + } + + ui.templates.ExecuteTemplate(w, "dashboard", data) +} +``` + +### Crew List Handler + +```go +type CrewMemberView struct { + RKey string + DID string + Handle string // Resolved from DID + Role string + Permissions []string + Tier string + TierLimit string // Human-readable + CurrentUsage int64 + UsageHuman string + UsagePercent int + Plankowner bool + AddedAt time.Time +} + +func (ui *AdminUI) handleCrewList(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + crew, err := ui.pds.ListCrewMembers(ctx) + if err != nil { + ui.renderError(w, "Failed to list crew: "+err.Error()) + return + } + + // Enrich with usage data + var crewViews []CrewMemberView + for _, member := range crew { + view := CrewMemberView{ + RKey: member.RKey, + DID: member.Member, + Role: member.Role, + Permissions: member.Permissions, + Tier: member.Tier, + Plankowner: member.Plankowner, + AddedAt: member.AddedAt, + } + + // Get tier limit + if limit := ui.quotaMgr.GetTierLimit(member.Tier); limit != nil { + view.TierLimit = quota.FormatHumanBytes(*limit) + } else { + view.TierLimit = "Unlimited" + } + + // Get usage (expensive - consider caching) + usage, _, tier, limit, _ := ui.pds.GetQuotaForUserWithTier(ctx, member.Member, ui.quotaMgr) + view.CurrentUsage = usage + view.UsageHuman = quota.FormatHumanBytes(usage) + if limit != nil && *limit > 0 { + view.UsagePercent = int(float64(usage) / float64(*limit) * 100) + } + + crewViews = append(crewViews, view) + } + + // Sort by usage (highest first) + sort.Slice(crewViews, func(i, j int) bool { + return crewViews[i].CurrentUsage > crewViews[j].CurrentUsage + }) + + data := struct { + AdminPageData + Crew []CrewMemberView + Tiers []TierOption + }{ + AdminPageData: ui.newPageData(r), + Crew: crewViews, + Tiers: ui.getTierOptions(), + } + + ui.templates.ExecuteTemplate(w, "crew", data) +} +``` + +### Add Crew Handler + +```go +func (ui *AdminUI) handleCrewAdd(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if err := r.ParseForm(); err != nil { + ui.setFlash(w, "error", "Invalid form data") + http.Redirect(w, r, "/admin/crew/add", http.StatusFound) + return + } + + did := strings.TrimSpace(r.FormValue("did")) + role := r.FormValue("role") + tier := r.FormValue("tier") + + // Parse permissions checkboxes + var permissions []string + if r.FormValue("perm_read") == "on" { + permissions = append(permissions, "blob:read") + } + if r.FormValue("perm_write") == "on" { + permissions = append(permissions, "blob:write") + } + if r.FormValue("perm_admin") == "on" { + permissions = append(permissions, "crew:admin") + } + + // Validate DID format + if !strings.HasPrefix(did, "did:") { + ui.setFlash(w, "error", "Invalid DID format") + http.Redirect(w, r, "/admin/crew/add", http.StatusFound) + return + } + + // Add crew member + _, err := ui.pds.AddCrewMember(ctx, did, role, permissions) + if err != nil { + ui.setFlash(w, "error", "Failed to add crew member: "+err.Error()) + http.Redirect(w, r, "/admin/crew/add", http.StatusFound) + return + } + + // Set tier if specified + if tier != "" && tier != ui.quotaMgr.GetDefaultTier() { + if err := ui.pds.UpdateCrewMemberTier(ctx, did, tier); err != nil { + slog.Warn("Failed to set tier for new crew member", "did", did, "tier", tier, "error", err) + } + } + + ui.setFlash(w, "success", "Crew member added successfully") + http.Redirect(w, r, "/admin/crew", http.StatusFound) +} +``` + +### Update Crew Handler + +```go +func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + rkey := chi.URLParam(r, "rkey") + + if err := r.ParseForm(); err != nil { + ui.setFlash(w, "error", "Invalid form data") + http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) + return + } + + // Get current crew member + current, err := ui.pds.GetCrewMemberByRKey(ctx, rkey) + if err != nil { + ui.setFlash(w, "error", "Crew member not found") + http.Redirect(w, r, "/admin/crew", http.StatusFound) + return + } + + // Parse new values + role := r.FormValue("role") + tier := r.FormValue("tier") + + var permissions []string + if r.FormValue("perm_read") == "on" { + permissions = append(permissions, "blob:read") + } + if r.FormValue("perm_write") == "on" { + permissions = append(permissions, "blob:write") + } + if r.FormValue("perm_admin") == "on" { + permissions = append(permissions, "crew:admin") + } + + // Update tier if changed + if tier != current.Tier { + if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil { + ui.setFlash(w, "error", "Failed to update tier: "+err.Error()) + http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) + return + } + } + + // For role/permissions changes, need to delete and recreate + // (ATProto records are immutable, updates require delete+create) + if role != current.Role || !slicesEqual(permissions, current.Permissions) { + // Delete old record + if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { + ui.setFlash(w, "error", "Failed to update: "+err.Error()) + http.Redirect(w, r, "/admin/crew/"+rkey, http.StatusFound) + return + } + + // Create new record with updated values + if _, err := ui.pds.AddCrewMember(ctx, current.Member, role, permissions); err != nil { + ui.setFlash(w, "error", "Failed to recreate crew record: "+err.Error()) + http.Redirect(w, r, "/admin/crew", http.StatusFound) + return + } + + // Re-apply tier to new record + if tier != "" { + ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier) + } + } + + ui.setFlash(w, "success", "Crew member updated successfully") + http.Redirect(w, r, "/admin/crew", http.StatusFound) +} +``` + +### Delete Crew Handler + +```go +func (ui *AdminUI) handleCrewDelete(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + rkey := chi.URLParam(r, "rkey") + + // Get crew member to log who was deleted + member, err := ui.pds.GetCrewMemberByRKey(ctx, rkey) + if err != nil { + ui.setFlash(w, "error", "Crew member not found") + http.Redirect(w, r, "/admin/crew", http.StatusFound) + return + } + + // Prevent deleting self (captain) + session := getAdminSession(ctx) + if member.Member == session.DID { + ui.setFlash(w, "error", "Cannot remove yourself from crew") + http.Redirect(w, r, "/admin/crew", http.StatusFound) + return + } + + // Delete + if err := ui.pds.RemoveCrewMember(ctx, rkey); err != nil { + ui.setFlash(w, "error", "Failed to remove crew member: "+err.Error()) + http.Redirect(w, r, "/admin/crew", http.StatusFound) + return + } + + slog.Info("Crew member removed via admin panel", "did", member.Member, "by", session.DID) + + // For HTMX requests, return empty response (row will be removed) + if r.Header.Get("HX-Request") == "true" { + w.WriteHeader(http.StatusOK) + return + } + + ui.setFlash(w, "success", "Crew member removed") + http.Redirect(w, r, "/admin/crew", http.StatusFound) +} +``` + +### Settings Handler + +```go +func (ui *AdminUI) handleSettings(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + _, captain, err := ui.pds.GetCaptainRecord(ctx) + if err != nil { + ui.renderError(w, "Failed to load settings: "+err.Error()) + return + } + + data := struct { + AdminPageData + Settings struct { + Public bool + AllowAllCrew bool + EnableBlueskyPosts bool + OwnerDID string + HoldDID string + } + }{ + AdminPageData: ui.newPageData(r), + } + data.Settings.Public = captain.Public + data.Settings.AllowAllCrew = captain.AllowAllCrew + data.Settings.EnableBlueskyPosts = captain.EnableBlueskyPosts + data.Settings.OwnerDID = captain.Owner + data.Settings.HoldDID = ui.pds.DID() + + ui.templates.ExecuteTemplate(w, "settings", data) +} + +func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if err := r.ParseForm(); err != nil { + ui.setFlash(w, "error", "Invalid form data") + http.Redirect(w, r, "/admin/settings", http.StatusFound) + return + } + + public := r.FormValue("public") == "on" + allowAllCrew := r.FormValue("allow_all_crew") == "on" + enablePosts := r.FormValue("enable_bluesky_posts") == "on" + + _, err := ui.pds.UpdateCaptainRecord(ctx, public, allowAllCrew, enablePosts) + if err != nil { + ui.setFlash(w, "error", "Failed to update settings: "+err.Error()) + http.Redirect(w, r, "/admin/settings", http.StatusFound) + return + } + + ui.setFlash(w, "success", "Settings updated successfully") + http.Redirect(w, r, "/admin/settings", http.StatusFound) +} +``` + +### Metrics Handler (for HTMX lazy loading) + +```go +func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Calculate total storage (expensive operation) + // Iterate through all layer records + records, _, err := ui.pds.RecordsIndex().ListRecords(atproto.LayerCollection, 100000, "", true) + if err != nil { + http.Error(w, "Failed to load stats", http.StatusInternalServerError) + return + } + + var totalSize int64 + uniqueDigests := make(map[string]bool) + userUsage := make(map[string]int64) + + for _, record := range records { + var layer atproto.LayerRecord + if err := json.Unmarshal(record.Value, &layer); err != nil { + continue + } + + if !uniqueDigests[layer.Digest] { + uniqueDigests[layer.Digest] = true + totalSize += layer.Size + } + + userUsage[layer.UserDID] += layer.Size + } + + stats := struct { + TotalBlobs int `json:"totalBlobs"` + TotalSize int64 `json:"totalSize"` + TotalHuman string `json:"totalHuman"` + }{ + TotalBlobs: len(uniqueDigests), + TotalSize: totalSize, + TotalHuman: quota.FormatHumanBytes(totalSize), + } + + // If HTMX request, return HTML partial + if r.Header.Get("HX-Request") == "true" { + data := struct { + Stats interface{} + }{Stats: stats} + ui.templates.ExecuteTemplate(w, "usage_stats", data) + return + } + + // Otherwise return JSON + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(stats) +} +``` + +--- + +## Templates + +### Base Layout (templates/base.html) + +```html +{{ define "base" }} + + + + + + {{ .Title }} - Hold Admin + {{ template "head" . }} + + + {{ template "nav" . }} + +
+ {{ template "flash" . }} + {{ template "content" . }} +
+ + + + +{{ end }} +``` + +### Head Component (templates/components/head.html) + +```html +{{ define "head" }} + + + +{{ end }} +``` + +### Navigation (templates/components/nav.html) + +```html +{{ define "nav" }} + +{{ end }} +``` + +### Dashboard Page (templates/pages/dashboard.html) + +```html +{{ define "dashboard" }} +{{ template "base" . }} +{{ define "content" }} +

Dashboard

+ +
+
+

Crew Members

+

{{ .Stats.TotalCrewMembers }}

+
+ +
+

Loading storage stats...

+
+
+ +
+

Tier Distribution

+
+ {{ range $tier, $count := .Stats.TierDistribution }} +
+ {{ $tier }} + {{ $count }} +
+ {{ end }} +
+
+ +
+

Top Users by Storage

+
+

Loading top users...

+
+
+{{ end }} +{{ end }} +``` + +### Crew List Page (templates/pages/crew.html) + +```html +{{ define "crew" }} +{{ template "base" . }} +{{ define "content" }} + + + + + + + + + + + + + + + {{ range .Crew }} + {{ template "crew_row" . }} + {{ end }} + +
DIDRolePermissionsTierUsageActions
+{{ end }} +{{ end }} +``` + +### Crew Row Partial (templates/partials/crew_row.html) + +```html +{{ define "crew_row" }} + + + {{ .DID | truncate 20 }} + {{ if .Plankowner }}Plankowner{{ end }} + + {{ .Role }} + + {{ range .Permissions }} + {{ . }} + {{ end }} + + + {{ .Tier }} + ({{ .TierLimit }}) + + +
+ {{ .UsageHuman }} +
+
+
+ {{ .UsagePercent }}% +
+ + + Edit + + + +{{ end }} +``` + +### Settings Page (templates/pages/settings.html) + +```html +{{ define "settings" }} +{{ template "base" . }} +{{ define "content" }} +

Hold Settings

+ +
+
+

Access Control

+ + + + +
+ +
+

Integrations

+ + +
+ +
+

Hold Information

+
+
Hold DID
+
{{ .Settings.HoldDID }}
+
Owner DID
+
{{ .Settings.OwnerDID }}
+
+
+ + +
+{{ end }} +{{ end }} +``` + +--- + +## Environment Variables + +Add to `.env.hold.example`: + +```bash +# ============================================================================= +# ADMIN PANEL CONFIGURATION +# ============================================================================= + +# Enable the admin web UI (default: false) +# When enabled, accessible at /admin +HOLD_ADMIN_ENABLED=false + +# Admin session duration (default: 24h) +# How long admin sessions remain valid before requiring re-authentication +# Format: Go duration string (e.g., 24h, 168h for 1 week) +HOLD_ADMIN_SESSION_DURATION=24h +``` + +### Config Struct Updates + +```go +// In pkg/hold/config.go + +type Config struct { + // ... existing fields ... + + Admin AdminConfig +} + +type AdminConfig struct { + Enabled bool `env:"HOLD_ADMIN_ENABLED" envDefault:"false"` + SessionDuration time.Duration `env:"HOLD_ADMIN_SESSION_DURATION" envDefault:"24h"` +} +``` + +--- + +## Security Considerations + +### 1. Owner-Only Access + +All admin routes validate that the authenticated user's DID matches `captain.Owner`. This check happens: +- In the OAuth callback (primary gate) +- In the `requireOwner` middleware (defense in depth) +- Before destructive operations (extra validation) + +### 2. Cookie Security + +```go +http.SetCookie(w, &http.Cookie{ + Name: "hold_admin_session", + Value: sessionID, + Path: "/admin", // Scoped to admin paths only + MaxAge: 86400, // 24 hours + HttpOnly: true, // No JavaScript access + Secure: isHTTPS(r), // HTTPS only in production + SameSite: http.SameSiteLaxMode, // CSRF protection +}) +``` + +### 3. CSRF Protection + +For state-changing operations: +- Forms include hidden CSRF token +- HTMX requests include token in header +- Server validates token before processing + +```html +
+ + ... +
+``` + +### 4. Input Validation + +- DID format validation before database operations +- Tier names validated against `quotas.yaml` +- Permission values validated against known set +- All user input sanitized before display + +### 5. Rate Limiting + +Consider adding rate limiting for: +- Login attempts (prevent brute force) +- OAuth flow starts (prevent abuse) +- API endpoints (prevent DoS) + +### 6. Audit Logging + +Log all administrative actions: +```go +slog.Info("Admin action", + "action", "crew_add", + "admin_did", session.DID, + "target_did", newMemberDID, + "permissions", permissions) +``` + +--- + +## Implementation Phases + +### Phase 1: Foundation (Est. 4-6 hours) + +1. Create `pkg/hold/admin/` package structure +2. Implement `AdminSessionStore` with SQLite +3. Implement OAuth client setup (reuse `pkg/auth/oauth/`) +4. Implement `requireOwner` middleware +5. Create basic template loading with embed.FS +6. Add env var configuration to `pkg/hold/config.go` + +**Deliverables:** +- Admin package compiles +- Can start OAuth flow +- Session store creates/validates sessions + +### Phase 2: Authentication (Est. 3-4 hours) + +1. Implement login page handler +2. Implement OAuth authorize redirect +3. Implement callback with owner validation +4. Implement logout handler +5. Wire up routes in `cmd/hold/main.go` + +**Deliverables:** +- Can login as hold owner +- Non-owners rejected at callback +- Sessions persist across requests + +### Phase 3: Dashboard (Est. 3-4 hours) + +1. Create base template and navigation +2. Implement dashboard handler with basic stats +3. Implement stats API for HTMX lazy loading +4. Implement top users API +5. Create dashboard template + +**Deliverables:** +- Dashboard shows crew count, tier distribution +- Storage stats load asynchronously +- Top users table displays + +### Phase 4: Crew Management (Est. 4-6 hours) + +1. Implement crew list handler +2. Create crew list template with HTMX delete +3. Implement add crew form and handler +4. Implement edit crew form and handler +5. Implement delete crew handler + +**Deliverables:** +- Full CRUD for crew members +- Tier and permission editing works +- HTMX updates without page reload + +### Phase 5: Settings (Est. 2-3 hours) + +1. Implement settings handler +2. Create settings template +3. Implement settings update handler + +**Deliverables:** +- Can toggle public/allowAllCrew/enableBlueskyPosts +- Settings persist correctly + +### Phase 6: Polish (Est. 2-4 hours) + +1. Add CSS styling +2. Add flash messages +3. Add CSRF protection +4. Add input validation +5. Add audit logging +6. Update documentation + +**Deliverables:** +- Professional-looking UI +- Security hardening complete +- Documentation updated + +**Total Estimated Time: 18-27 hours** + +--- + +## Testing Strategy + +### Unit Tests + +```go +// pkg/hold/admin/session_test.go +func TestSessionStore_Create(t *testing.T) { + store := newTestSessionStore(t) + + sessionID, err := store.Create("did:plc:test", "test.handle", 24*time.Hour) + require.NoError(t, err) + require.NotEmpty(t, sessionID) + + session, err := store.Get(sessionID) + require.NoError(t, err) + assert.Equal(t, "did:plc:test", session.DID) +} + +// pkg/hold/admin/auth_test.go +func TestRequireOwner_RejectsNonOwner(t *testing.T) { + pds := setupTestPDSWithOwner(t, "did:plc:owner") + store := newTestSessionStore(t) + + // Create session for non-owner + sessionID, _ := store.Create("did:plc:notowner", "notowner", 24*time.Hour) + + middleware := requireOwner(pds, store) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/admin", nil) + req.AddCookie(&http.Cookie{Name: "hold_admin_session", Value: sessionID}) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} +``` + +### Integration Tests + +```go +// pkg/hold/admin/integration_test.go +func TestAdminLoginFlow(t *testing.T) { + // Start test hold server + server := startTestHoldWithAdmin(t) + defer server.Close() + + // Verify login page accessible + resp, _ := http.Get(server.URL + "/admin/auth/login") + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify dashboard redirects to login + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, _ = client.Get(server.URL + "/admin") + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Location"), "/admin/auth/login") +} +``` + +### Manual Testing Checklist + +- [ ] Login as owner succeeds +- [ ] Login as non-owner fails with clear error +- [ ] Dashboard loads with correct stats +- [ ] Add crew member with all permission combinations +- [ ] Edit crew member permissions +- [ ] Change crew member tier +- [ ] Delete crew member +- [ ] Toggle public setting +- [ ] Toggle allowAllCrew setting +- [ ] Toggle enableBlueskyPosts setting +- [ ] Logout clears session +- [ ] Session expires after configured duration +- [ ] Expired session redirects to login + +--- + +## Future Enhancements + +### Potential Future Features + +1. **Crew Invite Links** - Generate one-time invite URLs for adding crew +2. **Usage Alerts** - Email/webhook when users approach quota +3. **Bulk Operations** - Add/remove multiple crew members at once +4. **Export Data** - Download crew list, usage reports as CSV +5. **Activity Log** - View recent admin actions +6. **API Keys** - Generate programmatic access keys for admin API +7. **Backup/Restore** - Backup crew records, restore from backup +8. **Multi-Hold Management** - Manage multiple holds from one UI (separate feature) + +### Performance Optimizations + +1. **Cache usage stats** - Don't recalculate on every request +2. **Paginate crew list** - Handle holds with 1000+ crew members +3. **Background stat refresh** - Update stats periodically in background +4. **Batch DID resolution** - Resolve multiple DIDs in parallel + +--- + +## References + +- [ATProto OAuth Specification](https://atproto.com/specs/oauth) +- [DPoP RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) +- [HTMX Documentation](https://htmx.org/docs/) +- [Chi Router](https://github.com/go-chi/chi) +- [Go html/template](https://pkg.go.dev/html/template) diff --git a/pkg/appview/db/oauth_store.go b/pkg/appview/db/oauth_store.go index 19c9b7a..e08baa0 100644 --- a/pkg/appview/db/oauth_store.go +++ b/pkg/appview/db/oauth_store.go @@ -8,6 +8,7 @@ import ( "log/slog" "time" + atoauth "atcr.io/pkg/auth/oauth" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" ) @@ -283,10 +284,15 @@ func (s *OAuthStore) InvalidateSessionsWithMismatchedScopes(ctx context.Context, continue } - // Check if scopes match (need to import oauth package for ScopesMatch) - // Since we're in db package, we can't import oauth (circular dependency) - // So we'll implement a simple scope comparison here - if !scopesMatch(sessionData.Scopes, desiredScopes) { + // Check if scopes match (expands include: scopes before comparing) + if !atoauth.ScopesMatch(sessionData.Scopes, desiredScopes) { + slog.Debug("Session has mismatched scopes", + "component", "oauth/store", + "session_key", sessionKey, + "account_did", accountDID, + "session_scopes", sessionData.Scopes, + "desired_scopes", desiredScopes, + ) sessionsToDelete = append(sessionsToDelete, sessionKey) } } @@ -311,30 +317,6 @@ func (s *OAuthStore) InvalidateSessionsWithMismatchedScopes(ctx context.Context, return len(sessionsToDelete), nil } -// scopesMatch checks if two scope lists are equivalent (order-independent) -// Local implementation to avoid circular dependency with oauth package -func scopesMatch(stored, desired []string) bool { - if len(stored) == 0 && len(desired) == 0 { - return true - } - if len(stored) != len(desired) { - return false - } - - desiredMap := make(map[string]bool, len(desired)) - for _, scope := range desired { - desiredMap[scope] = true - } - - for _, scope := range stored { - if !desiredMap[scope] { - return false - } - } - - return true -} - // GetSessionStats returns statistics about stored OAuth sessions // Useful for monitoring and debugging session health func (s *OAuthStore) GetSessionStats(ctx context.Context) (map[string]any, error) { diff --git a/pkg/appview/db/oauth_store_test.go b/pkg/appview/db/oauth_store_test.go index 9cb6b37..783cb28 100644 --- a/pkg/appview/db/oauth_store_test.go +++ b/pkg/appview/db/oauth_store_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + atcroauth "atcr.io/pkg/auth/oauth" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" ) @@ -161,7 +162,7 @@ func TestInvalidateSessionsWithMismatchedScopes(t *testing.T) { } func TestScopesMatch(t *testing.T) { - // Test the local scopesMatch function to ensure it matches the oauth.ScopesMatch behavior + // Test oauth.ScopesMatch function including include: scope expansion tests := []struct { name string stored []string @@ -204,13 +205,25 @@ func TestScopesMatch(t *testing.T) { desired: []string{}, expected: true, }, + { + name: "include scope expansion", + stored: []string{ + "atproto", + "repo?collection=io.atcr.manifest&collection=io.atcr.repo.page&collection=io.atcr.sailor.profile&collection=io.atcr.sailor.star&collection=io.atcr.tag", + }, + desired: []string{ + "atproto", + "include:io.atcr.authFullApp", + }, + expected: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := scopesMatch(tt.stored, tt.desired) + result := atcroauth.ScopesMatch(tt.stored, tt.desired) if result != tt.expected { - t.Errorf("scopesMatch(%v, %v) = %v, want %v", + t.Errorf("ScopesMatch(%v, %v) = %v, want %v", tt.stored, tt.desired, result, tt.expected) } }) diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go index 3d9128b..d6aab30 100644 --- a/pkg/auth/oauth/client.go +++ b/pkg/auth/oauth/client.go @@ -17,6 +17,38 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" ) +// permissionSetExpansions maps lexicon IDs to their expanded scope format. +// These must match the collections defined in lexicons/io/atcr/authFullApp.json +// Collections are sorted alphabetically for consistent comparison with PDS-expanded scopes. +var permissionSetExpansions = map[string]string{ + "io.atcr.authFullApp": "repo?" + + "collection=io.atcr.manifest&" + + "collection=io.atcr.repo.page&" + + "collection=io.atcr.sailor.profile&" + + "collection=io.atcr.sailor.star&" + + "collection=io.atcr.tag", +} + +// ExpandIncludeScopes expands any "include:" prefixed scopes to their full form +// by looking up the corresponding permission-set in the embedded lexicon files. +// For example, "include:io.atcr.authFullApp" expands to "repo?collection=io.atcr.manifest&..." +func ExpandIncludeScopes(scopes []string) []string { + var expanded []string + for _, scope := range scopes { + if strings.HasPrefix(scope, "include:") { + lexiconID := strings.TrimPrefix(scope, "include:") + if exp, ok := permissionSetExpansions[lexiconID]; ok { + expanded = append(expanded, exp) + } else { + expanded = append(expanded, scope) // Keep original if unknown + } + } else { + expanded = append(expanded, scope) + } + } + return expanded +} + // NewClientApp creates an indigo OAuth ClientApp with ATCR-specific configuration // Automatically configures confidential client for production deployments // keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost) @@ -97,19 +129,24 @@ func GetDefaultScopes(did string) []string { } // ScopesMatch checks if two scope lists are equivalent (order-independent) -// Returns true if both lists contain the same scopes, regardless of order +// Returns true if both lists contain the same scopes, regardless of order. +// Expands any "include:" prefixed scopes in the desired list before comparing, +// since the PDS returns expanded scopes in the stored session. func ScopesMatch(stored, desired []string) bool { + // Expand any include: scopes in desired before comparing + expandedDesired := ExpandIncludeScopes(desired) + // Handle nil/empty cases - if len(stored) == 0 && len(desired) == 0 { + if len(stored) == 0 && len(expandedDesired) == 0 { return true } - if len(stored) != len(desired) { + if len(stored) != len(expandedDesired) { return false } // Build map of desired scopes for O(1) lookup - desiredMap := make(map[string]bool, len(desired)) - for _, scope := range desired { + desiredMap := make(map[string]bool, len(expandedDesired)) + for _, scope := range expandedDesired { desiredMap[scope] = true }