diff --git a/.env.example b/.env.example index ad7bc8c..aa09185 100644 --- a/.env.example +++ b/.env.example @@ -1,41 +1,120 @@ # Court Command Environment Variables -# Copy to .env for local development. -# In Coolify, set these in the Docker Compose service's environment variables. +# +# Copy this file to .env for local development. The values below are the +# DEV defaults, matching what `make dev-up` brings up via +# docker-compose.dev.yml. See docs/LOCAL_DEV.md for the first-run flow. +# +# In production, Coolify sets these via its env var UI; the values +# below are illustrative only. The `# PRODUCTION` block at the bottom +# of this file documents the prod overrides. -# --- Database --- +# --- Database (local dev: db service in docker-compose.dev.yml) --- POSTGRES_USER=courtcommand POSTGRES_PASSWORD=courtcommand POSTGRES_DB=courtcommand -DATABASE_URL=postgres://courtcommand:courtcommand@db:5432/courtcommand?sslmode=disable +DATABASE_URL=postgres://courtcommand:courtcommand@localhost:5432/courtcommand?sslmode=disable -# --- Redis --- -REDIS_URL=redis://redis:6379/0 +# --- Redis (local dev: redis service in docker-compose.dev.yml) --- +REDIS_URL=redis://localhost:6379/0 # --- Backend --- PORT=8080 APP_ENV=development CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 -# --- Frontend (baked into build) --- +# --- Frontend (Vite dev server) --- VITE_API_URL=http://localhost:8080 VITE_GOOGLE_MAPS_API_KEY= -# --- Ghost CMS --- +# --- Ghost CMS (not used in local dev unless explicitly started) --- GHOST_URL=http://localhost:2368 -# --- Port Overrides (optional, for local conflicts) --- -# DB_PORT=5432 -# REDIS_PORT=6379 -# BACKEND_PORT=8080 -# FRONTEND_PORT=3000 -# GHOST_PORT=2368 +# ============================================================================= +# Logto (local dev: logto service in docker-compose.dev.yml on ports 3001/3002) +# ============================================================================= +# +# First-run flow: +# 1. `make dev-up` # starts postgres + redis + logto containers +# 2. Open http://localhost:3002 in a browser, create the initial admin +# account (Logto's first-time setup wizard), then create: +# - Application -> Machine-to-machine -> name "Court Command Backend" +# - Open it -> Roles -> assign "Logto Management API access" +# - Open it -> Settings -> copy App ID and App Secret into the +# two env vars below. +# 3. `make logto-seed` # provisions everything else automatically +# 4. Capture the seeder's output (it prints the values to paste back here) +# 5. `make dev` # starts the Go backend natively +# +# Subsequent runs: just `make dev-up` + `make dev`. + +# Core OIDC endpoint (issuer, JWKS, /oidc/token). +LOGTO_ENDPOINT=http://localhost:3001 +# Admin UI (humans only, not used by the backend). +LOGTO_ADMIN_ENDPOINT=http://localhost:3002 + +# API resource indicator registered in Logto -> API resources. +# Local dev points back at the Go backend's localhost address. +LOGTO_API_RESOURCE=http://localhost:8080/api + +# Machine-to-machine app credentials. Operator creates this in the +# Logto admin UI BEFORE the first run of `make logto-seed`. +LOGTO_MANAGEMENT_API_APP_ID= +LOGTO_MANAGEMENT_API_APP_SECRET= + +# Resource indicator for the built-in Logto Management API. This is a +# Logto-internal fixed value, NOT a real URL. Same in dev and prod. +LOGTO_MANAGEMENT_API_RESOURCE=https://default.logto.app/api + +# HMAC-SHA256 signing key from the webhook the seeder creates. +# Populated by the seeder's output; paste the printed value here. +LOGTO_WEBHOOK_SIGNING_KEY= + +# Sport organization IDs (created by the seeder). Populated by the +# seeder's output; paste the printed values here. +LOGTO_PICKLEBALL_ORG_ID= +LOGTO_DEMO_SPORT_ORG_ID= + +# Where the seeder will register the webhook URL. Logto runs in a +# Docker container, so it must reach the host-bound backend through +# host.docker.internal (Docker's host-gateway alias). +LOGTO_WEBHOOK_URL=http://host.docker.internal:8080/api/v1/webhooks/logto + +# Where the SPA app's redirect URI points (the Vite dev server). +LOGTO_SPA_REDIRECT_URI=http://localhost:5173/auth/callback + +# Bootstrap admin (created by the seeder). +# DO NOT USE THESE VALUES IN PRODUCTION. +LOGTO_BOOTSTRAP_EMAIL=admin@courtcommand.local +LOGTO_BOOTSTRAP_PASSWORD=TestPass123! +LOGTO_BOOTSTRAP_NAME="Local Admin" + +# Optional: Logto user ID of the bootstrap admin. Used ONLY by the live +# smoke test in api/logto/livesmoke_test.go when LOGTO_LIVE_SMOKE=1. +# Populated by the seeder's output (look for "user_id:" in the summary). +# LOGTO_BOOTSTRAP_USER_ID= + +# --- Frontend Logto config (baked into Vite build) --- +VITE_LOGTO_ENDPOINT=http://localhost:3001 +# SPA App ID from the seeder's output. +VITE_LOGTO_APP_ID= +VITE_LOGTO_API_RESOURCE=http://localhost:8080/api # ============================================================================= -# PRODUCTION (Coolify) — Override these values in Coolify's env var UI: +# PRODUCTION (Coolify) overrides -- set these in Coolify's env var UI: # ============================================================================= # POSTGRES_PASSWORD= # DATABASE_URL=postgres://courtcommand:@db:5432/courtcommand?sslmode=disable +# REDIS_URL=redis://redis:6379/0 # APP_ENV=production # CORS_ALLOWED_ORIGINS=https://courtcommand.app,https://news.courtcommand.app # VITE_API_URL=https://api.courtcommand.app # GHOST_URL=https://news.courtcommand.app +# LOGTO_ENDPOINT=https://logto.courtcommand.app +# LOGTO_ADMIN_ENDPOINT=https://logto-admin.courtcommand.app +# LOGTO_API_RESOURCE=https://api.courtcommand.app/api +# LOGTO_WEBHOOK_URL=https://api.courtcommand.app/api/v1/webhooks/logto +# LOGTO_SPA_REDIRECT_URI=https://courtcommand.app/auth/callback +# VITE_LOGTO_ENDPOINT=https://logto.courtcommand.app +# VITE_LOGTO_API_RESOURCE=https://api.courtcommand.app/api +# (Plus the LOGTO_MANAGEMENT_API_APP_ID/SECRET, sport org IDs, and +# webhook signing key captured during the production seed.) diff --git a/.gitignore b/.gitignore index 458c7b3..9acada5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # .gitignore .env +.env.prod +.env.production +.env.local *.exe *.dll *.so @@ -21,3 +24,18 @@ web/src/routeTree.gen.ts *.tsbuildinfo backups/ ghost-theme/cc-ghost-theme.zip + +# Playwright (Phase 3 Task 10) +web/test-results/ +web/playwright-report/ +web/blob-report/ + +# Local-only build outputs of the seeder (the binary is built into the +# api Docker image; never check in the local one) +api/logto-seed +api/court-command + +# Editor backups +*.save +*~ +*.swp diff --git a/Makefile b/Makefile index eb1de17..f6e0a9d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,76 @@ # Makefile -.PHONY: dev dev-frontend dev-all up down full full-down build migrate-up migrate-down migrate-create sqlc test test-db seed backup backup-full restore restore-db backup-list backup-before-deploy +.PHONY: dev dev-frontend dev-all dev-up dev-down dev-logs dev-reset up down full full-down build migrate-up migrate-down migrate-create sqlc test test-db seed logto-seed backup backup-full restore restore-db backup-list backup-before-deploy + +# ---- Local development (docker-compose.dev.yml) ---- +# Brings up postgres + redis + logto with host port bindings; the Go +# backend runs natively on the host for fast iteration. See +# docs/LOCAL_DEV.md for the full first-run walkthrough. + +# Start the dev infra (db + redis + logto) +dev-up: + docker compose -f docker-compose.dev.yml up -d + @echo "" + @echo "Dev infra ready:" + @echo " Postgres localhost:5432 (user=courtcommand pass=courtcommand db=courtcommand,logto)" + @echo " Redis localhost:6379" + @echo " Logto OIDC http://localhost:3001" + @echo " Logto admin http://localhost:3002" + @echo "" + @echo "Next: see docs/LOCAL_DEV.md for first-run setup." + +# Stop dev infra (preserves volumes / data) +dev-down: + docker compose -f docker-compose.dev.yml down + +# Tail logs from the dev infra services +dev-logs: + docker compose -f docker-compose.dev.yml logs -f + +# Wipe dev infra including all data (Postgres volume + Logto state) +dev-reset: + docker compose -f docker-compose.dev.yml down -v + @echo "Dev infra wiped. Run 'make dev-up' to start fresh." + +# Provision Logto (idempotent): creates apps, resources, scopes, org +# template, organizations, bootstrap admin, webhook. Reads config from +# .env (LOGTO_MANAGEMENT_API_APP_ID/SECRET must be set first -- see +# docs/LOCAL_DEV.md "First-run setup"). +logto-seed: + @if [ ! -f .env ]; then echo "ERROR: .env not found. Copy from .env.example first."; exit 1; fi + @cd api && set -a && . ../.env && set +a && go run ./cmd/logto-seed + +# Provision a production Logto tenant + sync sports.logto_org_id +# in the production app DB. Run ONCE at launch (re-running is safe; +# every step is idempotent). Env source order: +# 1. .env.prod (preferred -- gitignored, holds prod values) +# 2. .env (fallback for operators with a single env file) +# +# Required vars in the env file: +# LOGTO_ENDPOINT https://logto.courtcommand.app +# LOGTO_API_RESOURCE https://api.courtcommand.app/api +# LOGTO_MANAGEMENT_API_APP_ID (from Logto admin -> Apps -> M2M) +# LOGTO_MANAGEMENT_API_APP_SECRET (same place) +# LOGTO_MANAGEMENT_API_RESOURCE https://default.logto.app/api (Logto-internal, fixed) +# LOGTO_SPA_REDIRECT_URI https://courtcommand.app/auth/callback +# LOGTO_WEBHOOK_URL https://api.courtcommand.app/api/v1/webhooks/logto +# LOGTO_BOOTSTRAP_EMAIL/PASSWORD/NAME for the first admin +# DATABASE_URL points at the prod app DB (for sports.logto_org_id sync) +# APP_ENV must be "production" to skip Demo Sport +# +# Output: prints LOGTO_PICKLEBALL_ORG_ID, LOGTO_WEBHOOK_SIGNING_KEY, +# VITE_LOGTO_APP_ID etc. Paste into Coolify env, restart api+web. +prod-bootstrap: + @ENV_FILE=.env.prod; if [ ! -f $$ENV_FILE ]; then ENV_FILE=.env; fi; \ + if [ ! -f $$ENV_FILE ]; then echo "ERROR: neither .env.prod nor .env found"; exit 1; fi; \ + echo "Sourcing $$ENV_FILE"; \ + cd api && set -a && . ../$$ENV_FILE && set +a && \ + if [ "$$APP_ENV" != "production" ]; then \ + echo "ERROR: APP_ENV is not 'production' -- refusing to run prod-bootstrap with dev settings"; \ + exit 1; \ + fi; \ + go run ./cmd/logto-seed + +# ---- Legacy single-stack (docker-compose.yaml -- prod / Coolify shape) ---- # Start Docker services (db + redis only) up: @@ -60,11 +131,24 @@ test-db: up test: test-db cd api && go test ./... -v -count=1 -# Seed development data (all entity types — run after migrations) -seed: up - @echo "Seeding development data..." - docker compose exec -T db psql -U courtcommand -d courtcommand < api/db/seed.sql - @echo "Done! Login with admin@courtcommand.com / TestPass123!" +# Seed development domain data (orgs, tournaments, leagues, venues, +# matches, etc.) against the dev stack (docker-compose.dev.yml). +# Preserves the Logto-bootstrap admin row (logto_user_id IS NOT NULL); +# only wipes domain tables and shadow users. +# +# Prereqs: +# 1. make dev-up # postgres + redis + logto running +# 2. make migrate-up # schema is current +# 3. make logto-seed # Logto provisioned; bootstrap admin row exists +# 4. (sign in once via the SPA so the admin is mirrored to local users) +# +# After seeding, the bootstrap admin remains the only signin-capable +# user; all other users are shadow players (status='unclaimed') / staff +# fixtures with logto_user_id=NULL. +seed: + @echo "Seeding development domain data..." + docker compose -f docker-compose.dev.yml exec -T db psql -U courtcommand -d courtcommand < api/db/seed.sql + @echo "Done. Sign in via the SPA with the Logto bootstrap admin to see the seeded fixtures." # ---- Backup & Restore ---- @@ -75,12 +159,20 @@ backup: docker compose exec -T db pg_dump -U courtcommand courtcommand > backups/db-$$TIMESTAMP.sql && \ echo "Database backup: backups/db-$$TIMESTAMP.sql ($$(wc -c < backups/db-$$TIMESTAMP.sql | tr -d ' ') bytes)" -# Full backup: database + uploaded files (for before deploys or major changes) +# Full backup: app database + Logto identity database + uploaded files +# (for before deploys or major changes). Run as 'make backup-full' on +# the production host where the compose stack is running. backup-full: @mkdir -p backups @TIMESTAMP=$$(date +%Y%m%d-%H%M%S); \ docker compose exec -T db pg_dump -U courtcommand courtcommand > backups/db-$$TIMESTAMP.sql && \ - echo "Database backup: backups/db-$$TIMESTAMP.sql"; \ + echo "App db backup: backups/db-$$TIMESTAMP.sql"; \ + if docker compose ps -q db_logto >/dev/null 2>&1 && [ -n "$$(docker compose ps -q db_logto)" ]; then \ + docker compose exec -T db_logto pg_dump -U $${LOGTO_DB_USER:-logto} $${LOGTO_DB_NAME:-logto} > backups/db_logto-$$TIMESTAMP.sql && \ + echo "Logto db backup: backups/db_logto-$$TIMESTAMP.sql"; \ + else \ + echo "(db_logto service not running; skipped identity backup)"; \ + fi; \ if [ -d api/uploads ] && [ "$$(ls -A api/uploads 2>/dev/null)" ]; then \ tar czf backups/uploads-$$TIMESTAMP.tar.gz -C api uploads && \ echo "Uploads backup: backups/uploads-$$TIMESTAMP.tar.gz"; \ @@ -125,12 +217,24 @@ restore-uploads: # List all available backups backup-list: - @echo "=== Database Backups ===" + @echo "=== App database backups ===" @ls -lh backups/db-*.sql 2>/dev/null || echo " None" @echo "" - @echo "=== Upload Backups ===" + @echo "=== Logto identity database backups ===" + @ls -lh backups/db_logto-*.sql 2>/dev/null || echo " None" + @echo "" + @echo "=== Upload backups ===" @ls -lh backups/uploads-*.tar.gz 2>/dev/null || echo " None" +# Package the Court Command Ghost theme into a zip ready for upload +# at https://news.courtcommand.app/ghost (Admin -> Design -> Change theme). +# Output: ghost-theme/cc-ghost-theme.zip (gitignored). +ghost-theme: + @cd ghost-theme && rm -f cc-ghost-theme.zip && \ + zip -r cc-ghost-theme.zip . -x '*.zip' -x '.*' && \ + echo "Created ghost-theme/cc-ghost-theme.zip ($$(du -h cc-ghost-theme.zip | cut -f1))" + @echo "Upload via Ghost admin: Settings -> Design -> Change theme -> Upload." + # Include .env if it exists -include .env export diff --git a/api/Dockerfile b/api/Dockerfile index 47242d3..d54a3b2 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,5 +1,5 @@ # api/Dockerfile -# Multi-stage build for the Court Command API +# Multi-stage build for the Court Command API. # --- Stage 1: Build --- FROM golang:1.24-alpine AS builder @@ -15,11 +15,30 @@ WORKDIR /app COPY go.mod go.sum ./ RUN go mod download -# Copy source code +# Copy source code (build context is ./api) COPY . . -# Build the binary -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /court-command . +# Build the binary, injecting the build timestamp into the binary so +# /api/v1/health can report when the deployed image was built. The +# commit SHA falls back to "unknown" because Coolify (this version) +# does not expose SOURCE_COMMIT or COOLIFY_GIT_COMMIT_SHA at docker +# build time. The built_at timestamp alone is sufficient to verify a +# fresh deploy landed; pairing it with the local 'git log' tells you +# the exact commit by time. +# +# If a future Coolify version surfaces the SHA at build time, set +# COMMIT in docker-compose.yaml's build.args and the SHA will appear +# in the health response automatically. +ARG COMMIT +RUN RESOLVED_COMMIT="${COMMIT:-unknown}" \ + && BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + && echo "Building court-command commit=${RESOLVED_COMMIT} built_at=${BUILT_AT}" \ + && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w \ + -X github.com/court-command/court-command/handler.buildCommit=${RESOLVED_COMMIT} \ + -X github.com/court-command/court-command/handler.buildBuiltAt=${BUILT_AT}" \ + -o /court-command . \ + && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /logto-seed ./cmd/logto-seed # --- Stage 2: Run --- FROM alpine:3.20 @@ -31,12 +50,16 @@ RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app -# Copy binary from builder +# Copy binaries from builder. court-command is the API server; logto-seed +# is the one-shot Logto provisioning tool used by docker-compose.bootstrap.yaml +# to set up the Logto tenant + sync sports.logto_org_id on first deploy. COPY --from=builder /court-command . +COPY --from=builder /logto-seed . # Copy migrations (embedded in binary via go:embed, but also available for manual goose runs) COPY --from=builder /app/db/migrations ./db/migrations + # Create uploads directory RUN mkdir -p uploads && chown appuser:appgroup uploads diff --git a/api/auth/context.go b/api/auth/context.go new file mode 100644 index 0000000..5df10d3 --- /dev/null +++ b/api/auth/context.go @@ -0,0 +1,218 @@ +// Package auth provides JWT validation and Logto claims extraction for the +// Court Command API. It is consumed by the chi middleware that authenticates +// every protected request and stores the normalized Claims in the request +// context for downstream handlers. +package auth + +import ( + "context" + "strings" + + "github.com/lestrrat-go/jwx/v3/jwt" +) + +// Claims is the normalized subset of JWT claims Court Command cares about. +// It is intentionally narrower than the full set of fields jwx exposes so +// handlers don't have to know about jwx types. +type Claims struct { + Subject string // Logto user ID (sub claim) + OrganizationID string // organization_id claim, empty if not org-scoped + OrganizationRoles []string // organization_roles claim + Scopes []string // parsed scope claim (space-separated string -> slice) + Audience []string // aud claim + + // ActorSubject is the `act.sub` claim (RFC 8693). It is populated only on + // impersonation access tokens issued via OAuth 2.0 Token Exchange and + // carries the Logto user ID of the admin who is impersonating. Empty on + // all normal tokens. This is the backend's audit signal that a request is + // running under impersonation (see AdminHandler.StopImpersonation). + ActorSubject string +} + +// IsImpersonated reports whether this token was issued via token exchange and +// therefore carries an actor (impersonator) identity. +func (c Claims) IsImpersonated() bool { return c.ActorSubject != "" } + +// HasScope reports whether the token has the given OAuth scope. +func (c Claims) HasScope(scope string) bool { + for _, s := range c.Scopes { + if s == scope { + return true + } + } + return false +} + +// HasOrgRole reports whether the token's organization_roles contains role. +// Returns false for non-org-scoped tokens (where OrganizationRoles is empty). +func (c Claims) HasOrgRole(role string) bool { + for _, r := range c.OrganizationRoles { + if r == role { + return true + } + } + return false +} + +// orgRoleToLocalRole maps a Logto organization-role name to the local +// users.role string the rest of the app authorizes against. The Logto +// org-roles are defined by the seeder (api/logtoseed/seeder.go:orgRoles) +// and the local users.role values by the CHECK constraint in +// api/db/migrations/00030_expand_user_roles.sql. The five Logto org-roles +// happen to share names with their local counterparts, but we map +// explicitly rather than passing the string through so an unrecognized +// org-role never silently becomes a users.role value (which would violate +// the DB constraint and skip the intended authz surface). +// +// orgRolePriority orders the local roles from most to least privileged so +// ElevatedRole can pick the single highest role when a user holds several +// org-roles in the same org. platform_admin must always win. +var orgRoleToLocalRole = map[string]string{ + "platform_admin": "platform_admin", + "tournament_director": "tournament_director", + "referee": "referee", + "scorekeeper": "scorekeeper", + "player": "player", +} + +var orgRolePriority = map[string]int{ + "platform_admin": 5, + "tournament_director": 4, + "referee": 3, + "scorekeeper": 2, + "player": 1, +} + +// ElevatedRole maps the Logto org-roles in the token to the single local +// users.role string that handler-level authz checks (RequirePlatformAdmin, +// sidebar nav visibility, scoring/broadcast gating, etc.) expect. Returns +// "" when no recognized org-role is present and the caller should keep the +// local DB role. +// +// When the token carries multiple org-roles, the highest-privilege role +// wins (see orgRolePriority); platform_admin always takes precedence. +// Unrecognized org-role names are ignored so they can never map to a +// users.role value the DB constraint doesn't allow. +// +// IMPORTANT: relies on c.OrganizationRoles, which Logto only populates +// when the token is org-scoped (i.e. issued for audience +// urn:logto:organization:). The Court Command SPA always +// requests org-scoped tokens via getAccessToken(resource, orgID) so this +// works in practice. Logto also frequently omits the organization_roles +// claim from API-resource access tokens entirely; the JWTSession +// middleware falls back to the Logto Management API resolver in that case +// (see api/middleware/jwt_session.go). A future caller using a +// globally-scoped token will see an empty OrganizationRoles and no +// elevation will happen -- the user falls back to their local users.role. +func (c Claims) ElevatedRole() string { + best := "" + bestPriority := 0 + for _, role := range c.OrganizationRoles { + local, ok := orgRoleToLocalRole[role] + if !ok { + continue + } + if p := orgRolePriority[role]; p > bestPriority { + best = local + bestPriority = p + } + } + return best +} + +// ExtractClaims pulls Logto-shaped claims off a parsed jwx token. It is +// tolerant of the two ways jwx may surface the organization_roles array: +// when set programmatically in tests it arrives as []string; when parsed +// from a JSON token over the wire jwx surfaces it as []interface{}. +func ExtractClaims(token jwt.Token) Claims { + c := Claims{} + + if sub, ok := token.Subject(); ok { + c.Subject = sub + } + if aud, ok := token.Audience(); ok { + c.Audience = aud + } + + var orgID string + if err := token.Get("organization_id", &orgID); err == nil { + c.OrganizationID = orgID + } + + // organization_roles may arrive as []string or []interface{} depending on + // whether the token was constructed in-process or parsed from JSON. + var rolesAny interface{} + if err := token.Get("organization_roles", &rolesAny); err == nil { + c.OrganizationRoles = toStringSlice(rolesAny) + } + + var scopeStr string + if err := token.Get("scope", &scopeStr); err == nil && scopeStr != "" { + c.Scopes = strings.Fields(scopeStr) + } + + // act claim (RFC 8693). On impersonation tokens it is a nested object + // {"sub": ""}. Surfaced as ActorSubject for + // audit. Tolerant of both map[string]interface{} (parsed JSON) and + // map[string]string shapes. + var actAny interface{} + if err := token.Get("act", &actAny); err == nil { + c.ActorSubject = actorSubject(actAny) + } + + return c +} + +// actorSubject extracts the `sub` field from a parsed `act` claim, returning +// "" when the claim is absent or malformed. +func actorSubject(v interface{}) string { + switch m := v.(type) { + case map[string]interface{}: + if s, ok := m["sub"].(string); ok { + return s + } + case map[string]string: + return m["sub"] + } + return "" +} + +// toStringSlice coerces []string or []interface{} values into []string, +// dropping any element that isn't a string. Returns nil for any other type. +func toStringSlice(v interface{}) []string { + switch t := v.(type) { + case []string: + out := make([]string, len(t)) + copy(out, t) + return out + case []interface{}: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +// ctxKey is a private type so context keys defined in this package can never +// collide with keys defined elsewhere in the codebase. +type ctxKey struct{} + +var claimsKey = ctxKey{} + +// WithClaims returns a copy of ctx that carries c. Used by the JWT middleware +// after a token has been validated. +func WithClaims(ctx context.Context, c Claims) context.Context { + return context.WithValue(ctx, claimsKey, c) +} + +// ClaimsFromContext returns the Claims stored on ctx by WithClaims, if any. +// The bool return is false when there are no claims (unauthenticated request). +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + c, ok := ctx.Value(claimsKey).(Claims) + return c, ok +} diff --git a/api/auth/jwt.go b/api/auth/jwt.go new file mode 100644 index 0000000..bde891b --- /dev/null +++ b/api/auth/jwt.go @@ -0,0 +1,152 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" +) + +// ErrJWKSUnavailable is returned (via errors.Is) when Validate cannot reach +// the issuer's JWKS endpoint AND has no cached key set to fall back to. +// Middleware can branch on this to surface a 503 Service Unavailable +// instead of a 401, since the failure is infrastructure -- not a bad token. +var ErrJWKSUnavailable = errors.New("jwks unavailable") + +// defaultKeyTTL is how long a fetched JWKS is reused before we refetch from +// the issuer. Logto rotates signing keys infrequently; an hour balances +// freshness against load on the auth server. Per-Validator override via +// SetKeyTTL is provided so tests can force a refresh without sleeping. +const defaultKeyTTL = time.Hour + +// jwksFetchTimeout caps how long getKeySet will wait on the JWKS endpoint +// before giving up. The validator mutex is held across the fetch, so an +// unbounded wait against a hung issuer would stall every concurrent caller. +const jwksFetchTimeout = 10 * time.Second + +// orgAudiencePrefix is the prefix Logto stamps onto org-scoped access tokens. +// Tokens with audience starting with this prefix are accepted when callers +// pass orgScoped=true to Validate. +const orgAudiencePrefix = "urn:logto:organization:" + +// Validator parses and verifies JWTs issued by a Logto tenant. The set of +// signing keys is fetched lazily from the configured JWKS endpoint and cached +// for keyTTL between refreshes. +type Validator struct { + issuer string + jwksURI string + audience string + + mu sync.Mutex + cachedSet jwk.Set + cachedAt time.Time + keyTTL time.Duration +} + +// NewValidator builds a Validator scoped to the given Logto tenant. issuer +// must match the iss claim of incoming tokens; jwksURI is the .well-known +// JWKS URL; audience is the resource indicator the API was registered with +// in Logto (used as the default audience when orgScoped is false). +func NewValidator(issuer, jwksURI, audience string) *Validator { + return &Validator{ + issuer: issuer, + jwksURI: jwksURI, + audience: audience, + keyTTL: defaultKeyTTL, + } +} + +// SetKeyTTL overrides how long a fetched JWKS is cached before a refetch. +// Intended for tests that need to force a JWKS refresh deterministically; +// production code should rely on the default TTL set in NewValidator. +func (v *Validator) SetKeyTTL(d time.Duration) { + v.mu.Lock() + defer v.mu.Unlock() + v.keyTTL = d +} + +// Validate parses tokenString, verifies its signature against the cached +// JWKS, checks the issuer matches the configured issuer, and asserts the +// audience is acceptable. When orgScoped is true the token is also accepted +// if its audience begins with urn:logto:organization:; otherwise the audience +// must equal the Validator's configured audience exactly. The returned +// Claims is the normalized, jwx-free shape downstream handlers consume. +func (v *Validator) Validate(ctx context.Context, tokenString string, orgScoped bool) (Claims, error) { + keyset, err := v.getKeySet(ctx) + if err != nil { + return Claims{}, fmt.Errorf("load JWKS: %w", err) + } + + tok, err := jwt.Parse( + []byte(tokenString), + jwt.WithKeySet(keyset), + jwt.WithValidate(true), + jwt.WithIssuer(v.issuer), + ) + if err != nil { + return Claims{}, fmt.Errorf("parse token: %w", err) + } + + if err := v.checkAudience(tok, orgScoped); err != nil { + return Claims{}, err + } + + return ExtractClaims(tok), nil +} + +// checkAudience enforces the audience policy described on Validate. A token +// with no audience claim at all is rejected. +func (v *Validator) checkAudience(tok jwt.Token, orgScoped bool) error { + aud, ok := tok.Audience() + if !ok || len(aud) == 0 { + return fmt.Errorf("token missing audience claim") + } + for _, a := range aud { + if a == v.audience { + return nil + } + if orgScoped && strings.HasPrefix(a, orgAudiencePrefix) { + return nil + } + } + return fmt.Errorf("token audience %v does not match expected %q (orgScoped=%t)", aud, v.audience, orgScoped) +} + +// getKeySet returns the cached JWKS, refetching from the JWKS endpoint when +// the cache is empty or stale. Refresh is mutex-protected so concurrent +// requests after expiry coalesce into a single fetch. The fetch itself runs +// against a derived context with jwksFetchTimeout so a hung issuer can't +// stall the mutex for the lifetime of the caller's context. +func (v *Validator) getKeySet(ctx context.Context) (jwk.Set, error) { + v.mu.Lock() + defer v.mu.Unlock() + + if v.cachedSet != nil && time.Since(v.cachedAt) < v.keyTTL { + return v.cachedSet, nil + } + + fetchCtx, cancel := context.WithTimeout(ctx, jwksFetchTimeout) + defer cancel() + + set, err := jwk.Fetch(fetchCtx, v.jwksURI) + if err != nil { + // On refresh failure, fall back to the stale cache rather than + // rejecting every in-flight request — better availability while + // the issuer is briefly unreachable. With no cache, surface + // ErrJWKSUnavailable so middleware can return 503 instead of + // the misleading 401 it would otherwise produce. + if v.cachedSet != nil { + return v.cachedSet, nil + } + return nil, fmt.Errorf("fetch jwks: %w: %w", ErrJWKSUnavailable, err) + } + + v.cachedSet = set + v.cachedAt = time.Now() + return set, nil +} diff --git a/api/auth/jwt_test.go b/api/auth/jwt_test.go new file mode 100644 index 0000000..a356971 --- /dev/null +++ b/api/auth/jwt_test.go @@ -0,0 +1,112 @@ +package auth + +import ( + "testing" + + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/require" +) + +// TestExtractClaims verifies that ExtractClaims pulls all Logto-shaped claims +// (sub, aud, organization_id, organization_roles, scope) off a parsed jwx token +// into the normalized Claims struct. +func TestExtractClaims(t *testing.T) { + tok := jwt.New() + require.NoError(t, tok.Set(jwt.SubjectKey, "user_abc123")) + require.NoError(t, tok.Set(jwt.AudienceKey, []string{"urn:logto:organization:org_pickleball"})) + require.NoError(t, tok.Set("organization_id", "org_pickleball")) + require.NoError(t, tok.Set("organization_roles", []string{"platform_admin", "player"})) + require.NoError(t, tok.Set("scope", "read:tournaments write:tournaments read:admin write:admin")) + + c := ExtractClaims(tok) + + require.Equal(t, "user_abc123", c.Subject) + require.Equal(t, "org_pickleball", c.OrganizationID) + require.ElementsMatch(t, []string{"platform_admin", "player"}, c.OrganizationRoles) + require.ElementsMatch(t, + []string{"read:tournaments", "write:tournaments", "read:admin", "write:admin"}, + c.Scopes, + ) + require.Equal(t, []string{"urn:logto:organization:org_pickleball"}, c.Audience) + + require.True(t, c.HasScope("read:tournaments")) + require.False(t, c.HasScope("delete:tournaments")) + require.True(t, c.HasOrgRole("platform_admin")) + require.False(t, c.HasOrgRole("guest")) +} + +// TestExtractClaims_NoOrg covers a token without organization claims (e.g. a +// global resource token): OrganizationID and OrganizationRoles must be empty, +// Scopes must contain the single requested scope. +func TestExtractClaims_NoOrg(t *testing.T) { + tok := jwt.New() + require.NoError(t, tok.Set(jwt.SubjectKey, "user_xyz")) + require.NoError(t, tok.Set("scope", "read:profile")) + + c := ExtractClaims(tok) + + require.Equal(t, "user_xyz", c.Subject) + require.Empty(t, c.OrganizationID) + require.Empty(t, c.OrganizationRoles) + require.Equal(t, []string{"read:profile"}, c.Scopes) + require.False(t, c.HasOrgRole("platform_admin")) + require.True(t, c.HasScope("read:profile")) +} + +// TestExtractClaims_OrganizationRolesShapes exercises toStringSlice indirectly +// through ExtractClaims. The interesting branch is []interface{}: that's what +// jwx surfaces when a token is parsed off the wire (the only construction +// path used in production), but the in-process []string case from the other +// tests doesn't cover it. The non-slice and nil cases verify toStringSlice +// silently degrades to an empty result rather than panicking. +func TestExtractClaims_OrganizationRolesShapes(t *testing.T) { + cases := []struct { + name string + input interface{} + want []string + }{ + { + name: "string slice (in-process)", + input: []string{"a", "b"}, + want: []string{"a", "b"}, + }, + { + name: "interface slice of strings (wire-decoded)", + input: []interface{}{"a", "b"}, + want: []string{"a", "b"}, + }, + { + name: "interface slice with non-string element", + input: []interface{}{"a", 42, "b"}, + want: []string{"a", "b"}, + }, + { + name: "nil", + input: nil, + want: nil, + }, + { + name: "non-slice value", + input: "not-a-slice", + want: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tok := jwt.New() + require.NoError(t, tok.Set(jwt.SubjectKey, "user_shape")) + if tc.input != nil { + require.NoError(t, tok.Set("organization_roles", tc.input)) + } + + c := ExtractClaims(tok) + + if len(tc.want) == 0 { + require.Empty(t, c.OrganizationRoles) + return + } + require.Equal(t, tc.want, c.OrganizationRoles) + }) + } +} diff --git a/api/cmd/logto-seed/main.go b/api/cmd/logto-seed/main.go new file mode 100644 index 0000000..9fe1d42 --- /dev/null +++ b/api/cmd/logto-seed/main.go @@ -0,0 +1,128 @@ +// Command logto-seed is a thin CLI wrapper around the logtoseed package. +// +// As of the auto-bootstrap migration, the api itself invokes +// logtoseed.Run on every boot, so this binary is no longer required for +// normal operation. It is retained for: +// +// - Local development without the full api running (`make logto-seed`) +// - Operator debugging: re-running provisioning out-of-band against a +// misbehaving tenant, or running it once before the api starts (e.g. +// when standing up a brand-new environment and you want to capture +// the SPA app ID + webhook signing key BEFORE building the web image). +// - CI / automation that prefers an explicit one-shot to running the +// api for its side effects. +// +// All actual logic lives in api/logtoseed; this main is just env loading +// + a context, a database pool, an http client, and a summary printer. +// +// Required env vars: +// +// LOGTO_ENDPOINT e.g. http://localhost:3001 +// LOGTO_MANAGEMENT_API_APP_ID M2M app ID (operator-created in admin UI) +// LOGTO_MANAGEMENT_API_APP_SECRET M2M app secret +// +// See api/logtoseed/config.go for the full env reference. +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "time" + + "github.com/court-command/court-command/logto" + "github.com/court-command/court-command/logtoseed" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + // Match the api's structured-log convention so CLI output and api + // logs are interchangeable when piped to log collectors. + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + cfg, err := logtoseed.LoadConfigFromEnv() + if err != nil { + slog.Error("config error", "error", err) + os.Exit(1) + } + + client := logto.NewClient(logto.Config{ + Endpoint: cfg.Endpoint, + ManagementAPIAppID: cfg.MgmtAppID, + ManagementAPIAppSecret: cfg.MgmtAppSecret, + ManagementAPIResource: cfg.MgmtAPIResource, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // DATABASE_URL is optional for the CLI: an operator running this + // against a fresh Logto without the app stack up should still get + // orgs created, just without the local DB sync. The api always + // runs with a pool. + var pool *pgxpool.Pool + if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" { + p, err := pgxpool.New(ctx, dbURL) + if err != nil { + slog.Error("connect to app database", "error", err) + os.Exit(1) + } + defer p.Close() + pool = p + } else { + slog.Warn("DATABASE_URL not set -- skipping sports.logto_org_id sync; patch by hand using the org IDs printed below") + } + + result, err := logtoseed.Run(ctx, cfg, client, pool) + if err != nil { + slog.Error("logto seed failed", "error", err) + // Print whatever partial state we got so the operator can act on it. + printSummary(cfg, result) + os.Exit(1) + } + + printSummary(cfg, result) +} + +// printSummary writes the env values an operator needs to capture from a +// fresh seed. The api invokes Run silently and logs to slog -- this +// summary exists for the human at the terminal. +func printSummary(cfg *logtoseed.Config, r *logtoseed.Result) { + if r == nil { + return + } + fmt.Println() + fmt.Println("=================================================================") + fmt.Println(" Logto seed complete -- copy values below into your env if new") + fmt.Println("=================================================================") + fmt.Println() + fmt.Printf("LOGTO_ENDPOINT=%s\n", cfg.Endpoint) + fmt.Printf("LOGTO_API_RESOURCE=%s\n", cfg.APIResourceIndicator) + fmt.Printf("LOGTO_PICKLEBALL_ORG_ID=%s\n", r.PickleballOrgID) + if r.DemoSportOrgID != "" { + fmt.Printf("LOGTO_DEMO_SPORT_ORG_ID=%s\n", r.DemoSportOrgID) + } + if r.WebhookSigningKey != "" { + fmt.Printf("LOGTO_WEBHOOK_SIGNING_KEY=%s\n", r.WebhookSigningKey) + } + fmt.Println() + fmt.Println("# Frontend (Vite build args)") + fmt.Printf("VITE_LOGTO_ENDPOINT=%s\n", cfg.Endpoint) + fmt.Printf("VITE_LOGTO_APP_ID=%s\n", r.SPAAppID) + fmt.Printf("VITE_LOGTO_API_RESOURCE=%s\n", cfg.APIResourceIndicator) + fmt.Println() + if r.SPAAppCreated { + fmt.Println("** SPA app was CREATED on this run. The web image must be rebuilt with the VITE_LOGTO_APP_ID above. **") + } + if r.WebhookCreated { + fmt.Println("** Webhook was CREATED on this run. Set LOGTO_WEBHOOK_SIGNING_KEY in api env to the value above and redeploy the api. **") + } + fmt.Println("# Bootstrap admin (sign in at the SPA after webhook fires):") + fmt.Printf("# email: %s\n", cfg.BootstrapEmail) + fmt.Printf("# user_id: %s\n", r.BootstrapUserID) + fmt.Println() + fmt.Println("Re-running this CLI is safe; the api auto-runs the same logic on boot.") +} diff --git a/api/config/config.go b/api/config/config.go index 369f758..5cf3280 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -52,7 +52,18 @@ func Load() (*Config, error) { return cfg, nil } -// IsDevelopment returns true if running in development mode. +// IsDevelopment returns true if running in development mode. Treats +// empty Env (the default) as development so an unconfigured local +// stack doesn't accidentally trip production-only safety checks. func (c *Config) IsDevelopment() bool { - return c.Env == "development" + return c.Env == "" || c.Env == "development" || c.Env == "dev" || c.Env == "local" +} + +// IsProduction returns true when running in production mode. Anything +// that isn't a recognized dev marker counts as production -- this is +// the safe default so a misspelled APP_ENV (e.g. "prod" or "staging") +// still triggers production-only safety checks like the Logto Mgmt +// API fail-fast. +func (c *Config) IsProduction() bool { + return !c.IsDevelopment() } diff --git a/api/db/generated/activity_logs.sql.go b/api/db/generated/activity_logs.sql.go index 8709c1d..032949c 100644 --- a/api/db/generated/activity_logs.sql.go +++ b/api/db/generated/activity_logs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: activity_logs.sql package generated diff --git a/api/db/generated/ad_configs.sql.go b/api/db/generated/ad_configs.sql.go index 345703a..4de8543 100644 --- a/api/db/generated/ad_configs.sql.go +++ b/api/db/generated/ad_configs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: ad_configs.sql package generated diff --git a/api/db/generated/announcements.sql.go b/api/db/generated/announcements.sql.go index 88c0c5f..a777c1b 100644 --- a/api/db/generated/announcements.sql.go +++ b/api/db/generated/announcements.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: announcements.sql package generated diff --git a/api/db/generated/api_keys.sql.go b/api/db/generated/api_keys.sql.go index 02be816..50b5889 100644 --- a/api/db/generated/api_keys.sql.go +++ b/api/db/generated/api_keys.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: api_keys.sql package generated @@ -26,7 +26,7 @@ func (q *Queries) CountApiKeysByUser(ctx context.Context, userID int64) (int64, const createApiKey = `-- name: CreateApiKey :one INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, expires_at) VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at +RETURNING id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id ` type CreateApiKeyParams struct { @@ -60,6 +60,7 @@ func (q *Queries) CreateApiKey(ctx context.Context, arg CreateApiKeyParams) (Api &i.IsActive, &i.CreatedAt, &i.UpdatedAt, + &i.LogtoM2mAppID, ) return i, err } @@ -79,8 +80,70 @@ func (q *Queries) DeactivateApiKey(ctx context.Context, arg DeactivateApiKeyPara return err } +const getAPIKeyByLogtoM2MAppID = `-- name: GetAPIKeyByLogtoM2MAppID :one + +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys WHERE logto_m2m_app_id = $1 +` + +// ============================================================================ +// Logto M2M integration (Phase 2 additive). Phase 4 will rewrite +// CreateApiKey to delegate to logto.CreateM2MApp and persist the +// mirror row with logto_m2m_app_id; until then the legacy bcrypt +// key path keeps working. +// ============================================================================ +// Admin / management lookup by Logto M2M app ID. Does NOT filter on +// is_active because admin tools (Phase 4 webhook handlers, deactivation +// flows, audit trails) need to find rows regardless of state. The hot +// request-auth path uses GetActiveAPIKeyByLogtoM2MAppID instead. +func (q *Queries) GetAPIKeyByLogtoM2MAppID(ctx context.Context, logtoM2mAppID *string) (ApiKey, error) { + row := q.db.QueryRow(ctx, getAPIKeyByLogtoM2MAppID, logtoM2mAppID) + var i ApiKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.Scopes, + &i.ExpiresAt, + &i.LastUsedAt, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + &i.LogtoM2mAppID, + ) + return i, err +} + +const getActiveAPIKeyByLogtoM2MAppID = `-- name: GetActiveAPIKeyByLogtoM2MAppID :one +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys +WHERE logto_m2m_app_id = $1 AND is_active = true +` + +// Request-auth lookup: only returns the row if it is active. Mirrors +// the GetApiKeyByHash semantics on the legacy code path. +func (q *Queries) GetActiveAPIKeyByLogtoM2MAppID(ctx context.Context, logtoM2mAppID *string) (ApiKey, error) { + row := q.db.QueryRow(ctx, getActiveAPIKeyByLogtoM2MAppID, logtoM2mAppID) + var i ApiKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.Scopes, + &i.ExpiresAt, + &i.LastUsedAt, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + &i.LogtoM2mAppID, + ) + return i, err +} + const getApiKeyByHash = `-- name: GetApiKeyByHash :one -SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at FROM api_keys +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys WHERE key_hash = $1 AND is_active = true ` @@ -99,12 +162,13 @@ func (q *Queries) GetApiKeyByHash(ctx context.Context, keyHash string) (ApiKey, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, + &i.LogtoM2mAppID, ) return i, err } const getApiKeyByID = `-- name: GetApiKeyByID :one -SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at FROM api_keys WHERE id = $1 +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys WHERE id = $1 ` func (q *Queries) GetApiKeyByID(ctx context.Context, id int64) (ApiKey, error) { @@ -122,12 +186,13 @@ func (q *Queries) GetApiKeyByID(ctx context.Context, id int64) (ApiKey, error) { &i.IsActive, &i.CreatedAt, &i.UpdatedAt, + &i.LogtoM2mAppID, ) return i, err } const listApiKeysByUser = `-- name: ListApiKeysByUser :many -SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at FROM api_keys +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC ` @@ -153,6 +218,7 @@ func (q *Queries) ListApiKeysByUser(ctx context.Context, userID int64) ([]ApiKey &i.IsActive, &i.CreatedAt, &i.UpdatedAt, + &i.LogtoM2mAppID, ); err != nil { return nil, err } @@ -164,6 +230,39 @@ func (q *Queries) ListApiKeysByUser(ctx context.Context, userID int64) ([]ApiKey return items, nil } +const setAPIKeyLogtoM2MAppID = `-- name: SetAPIKeyLogtoM2MAppID :one +UPDATE api_keys SET + logto_m2m_app_id = $2, + updated_at = now() +WHERE id = $1 +RETURNING id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id +` + +type SetAPIKeyLogtoM2MAppIDParams struct { + ID int64 `json:"id"` + LogtoM2mAppID *string `json:"logto_m2m_app_id"` +} + +func (q *Queries) SetAPIKeyLogtoM2MAppID(ctx context.Context, arg SetAPIKeyLogtoM2MAppIDParams) (ApiKey, error) { + row := q.db.QueryRow(ctx, setAPIKeyLogtoM2MAppID, arg.ID, arg.LogtoM2mAppID) + var i ApiKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.Scopes, + &i.ExpiresAt, + &i.LastUsedAt, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + &i.LogtoM2mAppID, + ) + return i, err +} + const updateApiKeyLastUsed = `-- name: UpdateApiKeyLastUsed :exec UPDATE api_keys SET last_used_at = now() WHERE id = $1 diff --git a/api/db/generated/court_overlay_configs.sql.go b/api/db/generated/court_overlay_configs.sql.go index 847be28..8aeece2 100644 --- a/api/db/generated/court_overlay_configs.sql.go +++ b/api/db/generated/court_overlay_configs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: court_overlay_configs.sql package generated diff --git a/api/db/generated/courts.sql.go b/api/db/generated/courts.sql.go index 5db95d6..e880735 100644 --- a/api/db/generated/courts.sql.go +++ b/api/db/generated/courts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: courts.sql package generated diff --git a/api/db/generated/dashboard.sql.go b/api/db/generated/dashboard.sql.go index dfc442f..ea3a172 100644 --- a/api/db/generated/dashboard.sql.go +++ b/api/db/generated/dashboard.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: dashboard.sql package generated diff --git a/api/db/generated/db.go b/api/db/generated/db.go index c2b6408..260703d 100644 --- a/api/db/generated/db.go +++ b/api/db/generated/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 package generated diff --git a/api/db/generated/division_templates.sql.go b/api/db/generated/division_templates.sql.go index 0a5e9bc..2aed32e 100644 --- a/api/db/generated/division_templates.sql.go +++ b/api/db/generated/division_templates.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: division_templates.sql package generated diff --git a/api/db/generated/divisions.sql.go b/api/db/generated/divisions.sql.go index f8b8f39..5de16dc 100644 --- a/api/db/generated/divisions.sql.go +++ b/api/db/generated/divisions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: divisions.sql package generated @@ -36,7 +36,7 @@ INSERT INTO divisions ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30 -) RETURNING id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at +) RETURNING id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id ` type CreateDivisionParams struct { @@ -141,12 +141,13 @@ func (q *Queries) CreateDivision(ctx context.Context, arg CreateDivisionParams) &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getDivisionByID = `-- name: GetDivisionByID :one -SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at FROM divisions WHERE id = $1 AND deleted_at IS NULL +SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id FROM divisions WHERE id = $1 AND deleted_at IS NULL ` func (q *Queries) GetDivisionByID(ctx context.Context, id int64) (Division, error) { @@ -187,12 +188,13 @@ func (q *Queries) GetDivisionByID(ctx context.Context, id int64) (Division, erro &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getDivisionBySlug = `-- name: GetDivisionBySlug :one -SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at FROM divisions +SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id FROM divisions WHERE tournament_id = $1 AND slug = $2 AND deleted_at IS NULL ` @@ -239,12 +241,13 @@ func (q *Queries) GetDivisionBySlug(ctx context.Context, arg GetDivisionBySlugPa &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getDivisionsByIDs = `-- name: GetDivisionsByIDs :many -SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at FROM divisions +SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id FROM divisions WHERE id = ANY($1::bigint[]) AND deleted_at IS NULL ` @@ -292,6 +295,7 @@ func (q *Queries) GetDivisionsByIDs(ctx context.Context, dollar_1 []int64) ([]Di &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -304,7 +308,7 @@ func (q *Queries) GetDivisionsByIDs(ctx context.Context, dollar_1 []int64) ([]Di } const listDivisionsByTournament = `-- name: ListDivisionsByTournament :many -SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at FROM divisions +SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id FROM divisions WHERE tournament_id = $1 AND deleted_at IS NULL ORDER BY sort_order ASC, name ASC ` @@ -353,6 +357,7 @@ func (q *Queries) ListDivisionsByTournament(ctx context.Context, tournamentID in &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -423,7 +428,7 @@ UPDATE divisions SET allow_ref_player_add = COALESCE($29, allow_ref_player_add), updated_at = NOW() WHERE id = $30 AND deleted_at IS NULL -RETURNING id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at +RETURNING id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id ` type UpdateDivisionParams struct { @@ -528,6 +533,7 @@ func (q *Queries) UpdateDivision(ctx context.Context, arg UpdateDivisionParams) &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } diff --git a/api/db/generated/league_registrations.sql.go b/api/db/generated/league_registrations.sql.go index 5010e41..1831d31 100644 --- a/api/db/generated/league_registrations.sql.go +++ b/api/db/generated/league_registrations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: league_registrations.sql package generated diff --git a/api/db/generated/leagues.sql.go b/api/db/generated/leagues.sql.go index 4755886..d05fe20 100644 --- a/api/db/generated/leagues.sql.go +++ b/api/db/generated/leagues.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: leagues.sql package generated @@ -60,7 +60,7 @@ INSERT INTO leagues ( rules_document_url, social_links, sponsor_info, notes, created_by_user_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23 -) RETURNING id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +) RETURNING id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id ` type CreateLeagueParams struct { @@ -145,12 +145,13 @@ func (q *Queries) CreateLeague(ctx context.Context, arg CreateLeagueParams) (Lea &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getLeagueByID = `-- name: GetLeagueByID :one -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues WHERE id = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE id = $1 AND deleted_at IS NULL ` func (q *Queries) GetLeagueByID(ctx context.Context, id int64) (League, error) { @@ -185,12 +186,13 @@ func (q *Queries) GetLeagueByID(ctx context.Context, id int64) (League, error) { &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getLeagueByPublicID = `-- name: GetLeagueByPublicID :one -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues WHERE public_id = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE public_id = $1 AND deleted_at IS NULL ` func (q *Queries) GetLeagueByPublicID(ctx context.Context, publicID string) (League, error) { @@ -225,12 +227,13 @@ func (q *Queries) GetLeagueByPublicID(ctx context.Context, publicID string) (Lea &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getLeagueBySlug = `-- name: GetLeagueBySlug :one -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues WHERE slug = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE slug = $1 AND deleted_at IS NULL ` func (q *Queries) GetLeagueBySlug(ctx context.Context, slug string) (League, error) { @@ -265,12 +268,13 @@ func (q *Queries) GetLeagueBySlug(ctx context.Context, slug string) (League, err &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const listLeagues = `-- name: ListLeagues :many -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -319,6 +323,7 @@ func (q *Queries) ListLeagues(ctx context.Context, arg ListLeaguesParams) ([]Lea &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -331,7 +336,7 @@ func (q *Queries) ListLeagues(ctx context.Context, arg ListLeaguesParams) ([]Lea } const listLeaguesByCreator = `-- name: ListLeaguesByCreator :many -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE created_by_user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -381,6 +386,7 @@ func (q *Queries) ListLeaguesByCreator(ctx context.Context, arg ListLeaguesByCre &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -393,7 +399,7 @@ func (q *Queries) ListLeaguesByCreator(ctx context.Context, arg ListLeaguesByCre } const searchLeagues = `-- name: SearchLeagues :many -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE deleted_at IS NULL AND ( name ILIKE '%' || $3::TEXT || '%' @@ -449,6 +455,7 @@ func (q *Queries) SearchLeagues(ctx context.Context, arg SearchLeaguesParams) ([ &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -507,7 +514,7 @@ UPDATE leagues SET notes = COALESCE($22, notes), updated_at = NOW() WHERE id = $23 AND deleted_at IS NULL -RETURNING id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id ` type UpdateLeagueParams struct { @@ -592,6 +599,7 @@ func (q *Queries) UpdateLeague(ctx context.Context, arg UpdateLeagueParams) (Lea &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } diff --git a/api/db/generated/match_events.sql.go b/api/db/generated/match_events.sql.go index f6c46ca..b55306a 100644 --- a/api/db/generated/match_events.sql.go +++ b/api/db/generated/match_events.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: match_events.sql package generated diff --git a/api/db/generated/match_series.sql.go b/api/db/generated/match_series.sql.go index 46bc806..fbe334b 100644 --- a/api/db/generated/match_series.sql.go +++ b/api/db/generated/match_series.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: match_series.sql package generated diff --git a/api/db/generated/matches.sql.go b/api/db/generated/matches.sql.go index f082719..f2daaf7 100644 --- a/api/db/generated/matches.sql.go +++ b/api/db/generated/matches.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: matches.sql package generated diff --git a/api/db/generated/models.go b/api/db/generated/models.go index 20ab4d4..0c0d6c5 100644 --- a/api/db/generated/models.go +++ b/api/db/generated/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 package generated @@ -54,17 +54,18 @@ type Announcement struct { } type ApiKey struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - Name string `json:"name"` - KeyHash string `json:"key_hash"` - KeyPrefix string `json:"key_prefix"` - Scopes []string `json:"scopes"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` - LastUsedAt pgtype.Timestamptz `json:"last_used_at"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + KeyPrefix string `json:"key_prefix"` + Scopes []string `json:"scopes"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LogtoM2mAppID *string `json:"logto_m2m_app_id"` } type Court struct { @@ -140,6 +141,7 @@ type Division struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + SportID pgtype.Int8 `json:"sport_id"` } type DivisionTemplate struct { @@ -204,6 +206,7 @@ type League struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` } type LeagueRegistration struct { @@ -362,6 +365,36 @@ type Organization struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` +} + +type PlayerProfile struct { + UserID int64 `json:"user_id"` + Phone *string `json:"phone"` + DuprID *string `json:"dupr_id"` + VairID *string `json:"vair_id"` + PaddleBrand *string `json:"paddle_brand"` + PaddleModel *string `json:"paddle_model"` + Gender *string `json:"gender"` + Handedness *string `json:"handedness"` + DateOfBirth pgtype.Date `json:"date_of_birth"` + Bio *string `json:"bio"` + AddressLine1 *string `json:"address_line_1"` + AddressLine2 *string `json:"address_line_2"` + City *string `json:"city"` + StateProvince *string `json:"state_province"` + Country *string `json:"country"` + PostalCode *string `json:"postal_code"` + FormattedAddress *string `json:"formatted_address"` + Latitude pgtype.Float8 `json:"latitude"` + Longitude pgtype.Float8 `json:"longitude"` + EmergencyContactName *string `json:"emergency_contact_name"` + EmergencyContactPhone *string `json:"emergency_contact_phone"` + MedicalNotes *string `json:"medical_notes"` + WaiverAcceptedAt pgtype.Timestamptz `json:"waiver_accepted_at"` + AvatarUrl *string `json:"avatar_url"` + IsProfileHidden bool `json:"is_profile_hidden"` + UpdatedAt time.Time `json:"updated_at"` } type Pod struct { @@ -465,6 +498,17 @@ type SourceProfile struct { UpdatedAt time.Time `json:"updated_at"` } +type Sport struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + LogtoOrgID string `json:"logto_org_id"` + IsActive bool `json:"is_active"` + SortOrder int32 `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type StandingsEntry struct { ID int64 `json:"id"` SeasonID int64 `json:"season_id"` @@ -548,6 +592,7 @@ type Tournament struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + SportID pgtype.Int8 `json:"sport_id"` } type TournamentCourt struct { @@ -617,6 +662,7 @@ type User struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + LogtoUserID *string `json:"logto_user_id"` } type Venue struct { @@ -651,6 +697,7 @@ type Venue struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` } type VenueManager struct { diff --git a/api/db/generated/org_blocks.sql.go b/api/db/generated/org_blocks.sql.go index 2ae5cd6..7b22a56 100644 --- a/api/db/generated/org_blocks.sql.go +++ b/api/db/generated/org_blocks.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: org_blocks.sql package generated diff --git a/api/db/generated/org_memberships.sql.go b/api/db/generated/org_memberships.sql.go index 2122b9c..80f0007 100644 --- a/api/db/generated/org_memberships.sql.go +++ b/api/db/generated/org_memberships.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: org_memberships.sql package generated @@ -138,7 +138,7 @@ func (q *Queries) GetOrgMembers(ctx context.Context, orgID int64) ([]GetOrgMembe } const getPlayerOrgs = `-- name: GetPlayerOrgs :many -SELECT o.id, o.name, o.slug, o.logo_url, o.primary_color, o.secondary_color, o.website_url, o.contact_email, o.contact_phone, o.city, o.state_province, o.country, o.bio, o.founded_year, o.social_links, o.created_by_user_id, o.created_at, o.updated_at, o.deleted_at, o.address_line_1, o.address_line_2, o.postal_code, o.latitude, o.longitude, o.formatted_address, om.role AS membership_role, om.joined_at AS membership_joined_at +SELECT o.id, o.name, o.slug, o.logo_url, o.primary_color, o.secondary_color, o.website_url, o.contact_email, o.contact_phone, o.city, o.state_province, o.country, o.bio, o.founded_year, o.social_links, o.created_by_user_id, o.created_at, o.updated_at, o.deleted_at, o.address_line_1, o.address_line_2, o.postal_code, o.latitude, o.longitude, o.formatted_address, o.sport_id, om.role AS membership_role, om.joined_at AS membership_joined_at FROM org_memberships om JOIN organizations o ON o.id = om.org_id WHERE om.player_id = $1 AND om.left_at IS NULL AND o.deleted_at IS NULL @@ -171,6 +171,7 @@ type GetPlayerOrgsRow struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` MembershipRole string `json:"membership_role"` MembershipJoinedAt time.Time `json:"membership_joined_at"` } @@ -210,6 +211,7 @@ func (q *Queries) GetPlayerOrgs(ctx context.Context, playerID int64) ([]GetPlaye &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, &i.MembershipRole, &i.MembershipJoinedAt, ); err != nil { diff --git a/api/db/generated/organizations.sql.go b/api/db/generated/organizations.sql.go index 451513c..59b0ece 100644 --- a/api/db/generated/organizations.sql.go +++ b/api/db/generated/organizations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: organizations.sql package generated @@ -70,7 +70,7 @@ func (q *Queries) CountSearchOrgs(ctx context.Context, arg CountSearchOrgsParams const createOrganization = `-- name: CreateOrganization :one INSERT INTO organizations (name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, postal_code, address_line_1, address_line_2, formatted_address, latitude, longitude, bio, founded_year, social_links, created_by_user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) -RETURNING id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id ` type CreateOrganizationParams struct { @@ -148,12 +148,13 @@ func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganization &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getOrgByID = `-- name: GetOrgByID :one -SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM organizations +SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM organizations WHERE id = $1 AND deleted_at IS NULL ` @@ -186,12 +187,13 @@ func (q *Queries) GetOrgByID(ctx context.Context, id int64) (Organization, error &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getOrgBySlug = `-- name: GetOrgBySlug :one -SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM organizations +SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM organizations WHERE slug = $1 AND deleted_at IS NULL ` @@ -224,12 +226,13 @@ func (q *Queries) GetOrgBySlug(ctx context.Context, slug string) (Organization, &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const listOrgs = `-- name: ListOrgs :many -SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM organizations +SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM organizations WHERE deleted_at IS NULL ORDER BY name LIMIT $1 OFFSET $2 @@ -275,6 +278,7 @@ func (q *Queries) ListOrgs(ctx context.Context, arg ListOrgsParams) ([]Organizat &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -287,7 +291,7 @@ func (q *Queries) ListOrgs(ctx context.Context, arg ListOrgsParams) ([]Organizat } const listOrgsByUser = `-- name: ListOrgsByUser :many -SELECT o.id, o.name, o.slug, o.logo_url, o.primary_color, o.secondary_color, o.website_url, o.contact_email, o.contact_phone, o.city, o.state_province, o.country, o.bio, o.founded_year, o.social_links, o.created_by_user_id, o.created_at, o.updated_at, o.deleted_at, o.address_line_1, o.address_line_2, o.postal_code, o.latitude, o.longitude, o.formatted_address, om.role AS membership_role +SELECT o.id, o.name, o.slug, o.logo_url, o.primary_color, o.secondary_color, o.website_url, o.contact_email, o.contact_phone, o.city, o.state_province, o.country, o.bio, o.founded_year, o.social_links, o.created_by_user_id, o.created_at, o.updated_at, o.deleted_at, o.address_line_1, o.address_line_2, o.postal_code, o.latitude, o.longitude, o.formatted_address, o.sport_id, om.role AS membership_role FROM organizations o JOIN org_memberships om ON om.org_id = o.id WHERE om.player_id = $1 AND om.left_at IS NULL AND o.deleted_at IS NULL @@ -320,6 +324,7 @@ type ListOrgsByUserRow struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` MembershipRole string `json:"membership_role"` } @@ -358,6 +363,7 @@ func (q *Queries) ListOrgsByUser(ctx context.Context, playerID int64) ([]ListOrg &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, &i.MembershipRole, ); err != nil { return nil, err @@ -371,7 +377,7 @@ func (q *Queries) ListOrgsByUser(ctx context.Context, playerID int64) ([]ListOrg } const searchOrgs = `-- name: SearchOrgs :many -SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM organizations +SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM organizations WHERE deleted_at IS NULL AND ( $3::TEXT IS NULL @@ -435,6 +441,7 @@ func (q *Queries) SearchOrgs(ctx context.Context, arg SearchOrgsParams) ([]Organ &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -481,7 +488,7 @@ UPDATE organizations SET social_links = COALESCE($19, social_links), updated_at = now() WHERE id = $20 AND deleted_at IS NULL -RETURNING id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id ` type UpdateOrgParams struct { @@ -557,6 +564,7 @@ func (q *Queries) UpdateOrg(ctx context.Context, arg UpdateOrgParams) (Organizat &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } diff --git a/api/db/generated/player_profiles.sql.go b/api/db/generated/player_profiles.sql.go new file mode 100644 index 0000000..683e7db --- /dev/null +++ b/api/db/generated/player_profiles.sql.go @@ -0,0 +1,218 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: player_profiles.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deletePlayerProfile = `-- name: DeletePlayerProfile :exec +DELETE FROM player_profiles WHERE user_id = $1 +` + +func (q *Queries) DeletePlayerProfile(ctx context.Context, userID int64) error { + _, err := q.db.Exec(ctx, deletePlayerProfile, userID) + return err +} + +const getPlayerProfileRow = `-- name: GetPlayerProfileRow :one + +SELECT user_id, phone, dupr_id, vair_id, paddle_brand, paddle_model, gender, handedness, date_of_birth, bio, address_line_1, address_line_2, city, state_province, country, postal_code, formatted_address, latitude, longitude, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, avatar_url, is_profile_hidden, updated_at FROM player_profiles WHERE user_id = $1 +` + +// api/db/queries/player_profiles.sql +// Note: named with the 'Row' suffix because the legacy +// api/db/queries/players.sql still has a GetPlayerProfile that +// queries the users table. Phase 6 cutover removes that query and +// this one becomes the canonical lookup. +func (q *Queries) GetPlayerProfileRow(ctx context.Context, userID int64) (PlayerProfile, error) { + row := q.db.QueryRow(ctx, getPlayerProfileRow, userID) + var i PlayerProfile + err := row.Scan( + &i.UserID, + &i.Phone, + &i.DuprID, + &i.VairID, + &i.PaddleBrand, + &i.PaddleModel, + &i.Gender, + &i.Handedness, + &i.DateOfBirth, + &i.Bio, + &i.AddressLine1, + &i.AddressLine2, + &i.City, + &i.StateProvince, + &i.Country, + &i.PostalCode, + &i.FormattedAddress, + &i.Latitude, + &i.Longitude, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.AvatarUrl, + &i.IsProfileHidden, + &i.UpdatedAt, + ) + return i, err +} + +const upsertPlayerProfile = `-- name: UpsertPlayerProfile :one +INSERT INTO player_profiles ( + user_id, + phone, dupr_id, vair_id, paddle_brand, paddle_model, + gender, handedness, date_of_birth, bio, + address_line_1, address_line_2, city, state_province, country, + postal_code, formatted_address, latitude, longitude, + emergency_contact_name, emergency_contact_phone, medical_notes, + waiver_accepted_at, avatar_url, is_profile_hidden, + updated_at +) VALUES ( + $1::BIGINT, + $2, $3, $4, + $5, $6, + $7, $8, + $9, $10, + $11, $12, + $13, $14, $15, + $16, $17, + $18, $19, + $20, $21, + $22, + $23, $24, + COALESCE($25::BOOLEAN, false), + now() +) +ON CONFLICT (user_id) DO UPDATE SET + phone = COALESCE($2, player_profiles.phone), + dupr_id = COALESCE($3, player_profiles.dupr_id), + vair_id = COALESCE($4, player_profiles.vair_id), + paddle_brand = COALESCE($5, player_profiles.paddle_brand), + paddle_model = COALESCE($6, player_profiles.paddle_model), + gender = COALESCE($7, player_profiles.gender), + handedness = COALESCE($8, player_profiles.handedness), + date_of_birth = COALESCE($9, player_profiles.date_of_birth), + bio = COALESCE($10, player_profiles.bio), + address_line_1 = COALESCE($11, player_profiles.address_line_1), + address_line_2 = COALESCE($12, player_profiles.address_line_2), + city = COALESCE($13, player_profiles.city), + state_province = COALESCE($14, player_profiles.state_province), + country = COALESCE($15, player_profiles.country), + postal_code = COALESCE($16, player_profiles.postal_code), + formatted_address = COALESCE($17, player_profiles.formatted_address), + latitude = COALESCE($18, player_profiles.latitude), + longitude = COALESCE($19, player_profiles.longitude), + emergency_contact_name = COALESCE($20, player_profiles.emergency_contact_name), + emergency_contact_phone = COALESCE($21, player_profiles.emergency_contact_phone), + medical_notes = COALESCE($22, player_profiles.medical_notes), + waiver_accepted_at = COALESCE($23, player_profiles.waiver_accepted_at), + avatar_url = COALESCE($24, player_profiles.avatar_url), + is_profile_hidden = COALESCE($25, player_profiles.is_profile_hidden), + updated_at = now() +RETURNING user_id, phone, dupr_id, vair_id, paddle_brand, paddle_model, gender, handedness, date_of_birth, bio, address_line_1, address_line_2, city, state_province, country, postal_code, formatted_address, latitude, longitude, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, avatar_url, is_profile_hidden, updated_at +` + +type UpsertPlayerProfileParams struct { + UserID int64 `json:"user_id"` + Phone *string `json:"phone"` + DuprID *string `json:"dupr_id"` + VairID *string `json:"vair_id"` + PaddleBrand *string `json:"paddle_brand"` + PaddleModel *string `json:"paddle_model"` + Gender *string `json:"gender"` + Handedness *string `json:"handedness"` + DateOfBirth pgtype.Date `json:"date_of_birth"` + Bio *string `json:"bio"` + AddressLine1 *string `json:"address_line_1"` + AddressLine2 *string `json:"address_line_2"` + City *string `json:"city"` + StateProvince *string `json:"state_province"` + Country *string `json:"country"` + PostalCode *string `json:"postal_code"` + FormattedAddress *string `json:"formatted_address"` + Latitude pgtype.Float8 `json:"latitude"` + Longitude pgtype.Float8 `json:"longitude"` + EmergencyContactName *string `json:"emergency_contact_name"` + EmergencyContactPhone *string `json:"emergency_contact_phone"` + MedicalNotes *string `json:"medical_notes"` + WaiverAcceptedAt pgtype.Timestamptz `json:"waiver_accepted_at"` + AvatarUrl *string `json:"avatar_url"` + IsProfileHidden pgtype.Bool `json:"is_profile_hidden"` +} + +// Insert or partial-update the 1:1 profile row. Every field is optional +// (sqlc.narg). On INSERT, NULL nargs become NULL in the new row. On +// UPDATE, NULL nargs leave the existing column value unchanged via +// COALESCE. is_profile_hidden defaults to false on insert. +// +// This shape mirrors the existing players.sql:UpdatePlayerProfile narg +// pattern, so a Phase 3 profile-edit form that sends only the fields +// the user actually changed will not blow away the rest. To explicitly +// clear a field, pass an empty-string-coerced override at the call +// site or add a dedicated ClearPlayerProfileField query. +func (q *Queries) UpsertPlayerProfile(ctx context.Context, arg UpsertPlayerProfileParams) (PlayerProfile, error) { + row := q.db.QueryRow(ctx, upsertPlayerProfile, + arg.UserID, + arg.Phone, + arg.DuprID, + arg.VairID, + arg.PaddleBrand, + arg.PaddleModel, + arg.Gender, + arg.Handedness, + arg.DateOfBirth, + arg.Bio, + arg.AddressLine1, + arg.AddressLine2, + arg.City, + arg.StateProvince, + arg.Country, + arg.PostalCode, + arg.FormattedAddress, + arg.Latitude, + arg.Longitude, + arg.EmergencyContactName, + arg.EmergencyContactPhone, + arg.MedicalNotes, + arg.WaiverAcceptedAt, + arg.AvatarUrl, + arg.IsProfileHidden, + ) + var i PlayerProfile + err := row.Scan( + &i.UserID, + &i.Phone, + &i.DuprID, + &i.VairID, + &i.PaddleBrand, + &i.PaddleModel, + &i.Gender, + &i.Handedness, + &i.DateOfBirth, + &i.Bio, + &i.AddressLine1, + &i.AddressLine2, + &i.City, + &i.StateProvince, + &i.Country, + &i.PostalCode, + &i.FormattedAddress, + &i.Latitude, + &i.Longitude, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.AvatarUrl, + &i.IsProfileHidden, + &i.UpdatedAt, + ) + return i, err +} diff --git a/api/db/generated/players.sql.go b/api/db/generated/players.sql.go index 4c27c9a..1f60c2a 100644 --- a/api/db/generated/players.sql.go +++ b/api/db/generated/players.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: players.sql package generated @@ -16,7 +16,7 @@ UPDATE users SET waiver_accepted_at = now(), updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` func (q *Queries) AcceptWaiver(ctx context.Context, id int64) (User, error) { @@ -60,6 +60,7 @@ func (q *Queries) AcceptWaiver(ctx context.Context, id int64) (User, error) { &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -101,7 +102,7 @@ func (q *Queries) CountSearchPlayers(ctx context.Context, arg CountSearchPlayers } const getPlayerByDuprID = `-- name: GetPlayerByDuprID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE dupr_id = $1 AND deleted_at IS NULL ` @@ -146,12 +147,13 @@ func (q *Queries) GetPlayerByDuprID(ctx context.Context, duprID *string) (User, &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getPlayerByPublicID = `-- name: GetPlayerByPublicID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE public_id = $1 AND deleted_at IS NULL ` @@ -196,12 +198,13 @@ func (q *Queries) GetPlayerByPublicID(ctx context.Context, publicID string) (Use &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getPlayerByVairID = `-- name: GetPlayerByVairID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE vair_id = $1 AND deleted_at IS NULL ` @@ -246,13 +249,14 @@ func (q *Queries) GetPlayerByVairID(ctx context.Context, vairID *string) (User, &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getPlayerProfile = `-- name: GetPlayerProfile :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE id = $1 AND deleted_at IS NULL ` @@ -298,12 +302,13 @@ func (q *Queries) GetPlayerProfile(ctx context.Context, id int64) (User, error) &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const listPlayers = `-- name: ListPlayers :many -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE deleted_at IS NULL AND status != 'merged' ORDER BY display_name ASC NULLS LAST, last_name ASC, first_name ASC @@ -362,6 +367,7 @@ func (q *Queries) ListPlayers(ctx context.Context, arg ListPlayersParams) ([]Use &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ); err != nil { return nil, err } @@ -374,7 +380,7 @@ func (q *Queries) ListPlayers(ctx context.Context, arg ListPlayersParams) ([]Use } const searchPlayers = `-- name: SearchPlayers :many -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE deleted_at IS NULL AND status != 'merged' AND is_profile_hidden = false @@ -455,6 +461,7 @@ func (q *Queries) SearchPlayers(ctx context.Context, arg SearchPlayersParams) ([ &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ); err != nil { return nil, err } @@ -493,7 +500,7 @@ UPDATE users SET is_profile_hidden = COALESCE($23, is_profile_hidden), updated_at = now() WHERE id = $24 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdatePlayerProfileParams struct { @@ -589,6 +596,7 @@ func (q *Queries) UpdatePlayerProfile(ctx context.Context, arg UpdatePlayerProfi &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } diff --git a/api/db/generated/pods.sql.go b/api/db/generated/pods.sql.go index cc6a1a1..786a727 100644 --- a/api/db/generated/pods.sql.go +++ b/api/db/generated/pods.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: pods.sql package generated diff --git a/api/db/generated/registrations.sql.go b/api/db/generated/registrations.sql.go index 9c1540d..149dcc1 100644 --- a/api/db/generated/registrations.sql.go +++ b/api/db/generated/registrations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: registrations.sql package generated diff --git a/api/db/generated/scoring_presets.sql.go b/api/db/generated/scoring_presets.sql.go index e823cf1..31e865f 100644 --- a/api/db/generated/scoring_presets.sql.go +++ b/api/db/generated/scoring_presets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: scoring_presets.sql package generated diff --git a/api/db/generated/search.sql.go b/api/db/generated/search.sql.go index 45cd92e..e1523e8 100644 --- a/api/db/generated/search.sql.go +++ b/api/db/generated/search.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: search.sql package generated diff --git a/api/db/generated/season_confirmations.sql.go b/api/db/generated/season_confirmations.sql.go index e3a3a8a..14ead39 100644 --- a/api/db/generated/season_confirmations.sql.go +++ b/api/db/generated/season_confirmations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: season_confirmations.sql package generated diff --git a/api/db/generated/seasons.sql.go b/api/db/generated/seasons.sql.go index 5e86c57..45bda82 100644 --- a/api/db/generated/seasons.sql.go +++ b/api/db/generated/seasons.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: seasons.sql package generated diff --git a/api/db/generated/source_profiles.sql.go b/api/db/generated/source_profiles.sql.go index 239c2b3..f9e5bf7 100644 --- a/api/db/generated/source_profiles.sql.go +++ b/api/db/generated/source_profiles.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: source_profiles.sql package generated diff --git a/api/db/generated/sports.sql.go b/api/db/generated/sports.sql.go new file mode 100644 index 0000000..48226af --- /dev/null +++ b/api/db/generated/sports.sql.go @@ -0,0 +1,107 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: sports.sql + +package generated + +import ( + "context" +) + +const getSportByID = `-- name: GetSportByID :one +SELECT id, slug, name, logto_org_id, is_active, sort_order, created_at, updated_at FROM sports WHERE id = $1 +` + +func (q *Queries) GetSportByID(ctx context.Context, id int64) (Sport, error) { + row := q.db.QueryRow(ctx, getSportByID, id) + var i Sport + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.LogtoOrgID, + &i.IsActive, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSportByLogtoOrgID = `-- name: GetSportByLogtoOrgID :one +SELECT id, slug, name, logto_org_id, is_active, sort_order, created_at, updated_at FROM sports WHERE logto_org_id = $1 +` + +func (q *Queries) GetSportByLogtoOrgID(ctx context.Context, logtoOrgID string) (Sport, error) { + row := q.db.QueryRow(ctx, getSportByLogtoOrgID, logtoOrgID) + var i Sport + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.LogtoOrgID, + &i.IsActive, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSportBySlug = `-- name: GetSportBySlug :one +SELECT id, slug, name, logto_org_id, is_active, sort_order, created_at, updated_at FROM sports WHERE slug = $1 +` + +func (q *Queries) GetSportBySlug(ctx context.Context, slug string) (Sport, error) { + row := q.db.QueryRow(ctx, getSportBySlug, slug) + var i Sport + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.LogtoOrgID, + &i.IsActive, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listSports = `-- name: ListSports :many + +SELECT id, slug, name, logto_org_id, is_active, sort_order, created_at, updated_at FROM sports +WHERE is_active = true +ORDER BY sort_order, name +` + +// api/db/queries/sports.sql +func (q *Queries) ListSports(ctx context.Context) ([]Sport, error) { + rows, err := q.db.Query(ctx, listSports) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Sport{} + for rows.Next() { + var i Sport + if err := rows.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.LogtoOrgID, + &i.IsActive, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/api/db/generated/standings_entries.sql.go b/api/db/generated/standings_entries.sql.go index f841326..e06c003 100644 --- a/api/db/generated/standings_entries.sql.go +++ b/api/db/generated/standings_entries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: standings_entries.sql package generated diff --git a/api/db/generated/team_rosters.sql.go b/api/db/generated/team_rosters.sql.go index 1a1310e..8e878f3 100644 --- a/api/db/generated/team_rosters.sql.go +++ b/api/db/generated/team_rosters.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: team_rosters.sql package generated diff --git a/api/db/generated/teams.sql.go b/api/db/generated/teams.sql.go index 533d966..efa194f 100644 --- a/api/db/generated/teams.sql.go +++ b/api/db/generated/teams.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: teams.sql package generated diff --git a/api/db/generated/tournament_courts.sql.go b/api/db/generated/tournament_courts.sql.go index 7bcbbc2..da55413 100644 --- a/api/db/generated/tournament_courts.sql.go +++ b/api/db/generated/tournament_courts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: tournament_courts.sql package generated diff --git a/api/db/generated/tournament_staff.sql.go b/api/db/generated/tournament_staff.sql.go index cc1d7ff..8672e11 100644 --- a/api/db/generated/tournament_staff.sql.go +++ b/api/db/generated/tournament_staff.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: tournament_staff.sql package generated diff --git a/api/db/generated/tournaments.sql.go b/api/db/generated/tournaments.sql.go index baa34b6..53f0aca 100644 --- a/api/db/generated/tournaments.sql.go +++ b/api/db/generated/tournaments.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: tournaments.sql package generated @@ -107,7 +107,7 @@ INSERT INTO tournaments ( ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24 -) RETURNING id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at +) RETURNING id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id ` type CreateTournamentParams struct { @@ -196,12 +196,13 @@ func (q *Queries) CreateTournament(ctx context.Context, arg CreateTournamentPara &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getTournamentByID = `-- name: GetTournamentByID :one -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments WHERE id = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE id = $1 AND deleted_at IS NULL ` func (q *Queries) GetTournamentByID(ctx context.Context, id int64) (Tournament, error) { @@ -238,12 +239,13 @@ func (q *Queries) GetTournamentByID(ctx context.Context, id int64) (Tournament, &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getTournamentByPublicID = `-- name: GetTournamentByPublicID :one -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments WHERE public_id = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE public_id = $1 AND deleted_at IS NULL ` func (q *Queries) GetTournamentByPublicID(ctx context.Context, publicID string) (Tournament, error) { @@ -280,12 +282,13 @@ func (q *Queries) GetTournamentByPublicID(ctx context.Context, publicID string) &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getTournamentBySlug = `-- name: GetTournamentBySlug :one -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments WHERE slug = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE slug = $1 AND deleted_at IS NULL ` func (q *Queries) GetTournamentBySlug(ctx context.Context, slug string) (Tournament, error) { @@ -322,12 +325,13 @@ func (q *Queries) GetTournamentBySlug(ctx context.Context, slug string) (Tournam &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getTournamentsByIDs = `-- name: GetTournamentsByIDs :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE id = ANY($1::bigint[]) AND deleted_at IS NULL ` @@ -371,6 +375,7 @@ func (q *Queries) GetTournamentsByIDs(ctx context.Context, dollar_1 []int64) ([] &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -383,7 +388,7 @@ func (q *Queries) GetTournamentsByIDs(ctx context.Context, dollar_1 []int64) ([] } const listTournaments = `-- name: ListTournaments :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE deleted_at IS NULL ORDER BY start_date DESC LIMIT $1 OFFSET $2 @@ -434,6 +439,7 @@ func (q *Queries) ListTournaments(ctx context.Context, arg ListTournamentsParams &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -446,7 +452,7 @@ func (q *Queries) ListTournaments(ctx context.Context, arg ListTournamentsParams } const listTournamentsByCreator = `-- name: ListTournamentsByCreator :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE (created_by_user_id = $1 OR td_user_id = $1) AND deleted_at IS NULL ORDER BY start_date DESC LIMIT $2 OFFSET $3 @@ -498,6 +504,7 @@ func (q *Queries) ListTournamentsByCreator(ctx context.Context, arg ListTourname &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -510,7 +517,7 @@ func (q *Queries) ListTournamentsByCreator(ctx context.Context, arg ListTourname } const listTournamentsByLeague = `-- name: ListTournamentsByLeague :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE league_id = $1 AND deleted_at IS NULL ORDER BY start_date DESC LIMIT $2 OFFSET $3 @@ -562,6 +569,7 @@ func (q *Queries) ListTournamentsByLeague(ctx context.Context, arg ListTournamen &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -574,7 +582,7 @@ func (q *Queries) ListTournamentsByLeague(ctx context.Context, arg ListTournamen } const listTournamentsBySeason = `-- name: ListTournamentsBySeason :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE season_id = $1 AND deleted_at IS NULL ORDER BY start_date ASC ` @@ -619,6 +627,7 @@ func (q *Queries) ListTournamentsBySeason(ctx context.Context, seasonID pgtype.I &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -631,7 +640,7 @@ func (q *Queries) ListTournamentsBySeason(ctx context.Context, seasonID pgtype.I } const listTournamentsByStatus = `-- name: ListTournamentsByStatus :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE status = $1 AND deleted_at IS NULL ORDER BY start_date ASC LIMIT $2 OFFSET $3 @@ -683,6 +692,7 @@ func (q *Queries) ListTournamentsByStatus(ctx context.Context, arg ListTournamen &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -695,7 +705,7 @@ func (q *Queries) ListTournamentsByStatus(ctx context.Context, arg ListTournamen } const searchTournaments = `-- name: SearchTournaments :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE deleted_at IS NULL AND ( name ILIKE '%' || $3::TEXT || '%' @@ -751,6 +761,7 @@ func (q *Queries) SearchTournaments(ctx context.Context, arg SearchTournamentsPa &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -763,7 +774,7 @@ func (q *Queries) SearchTournaments(ctx context.Context, arg SearchTournamentsPa } const searchTournamentsByStatus = `-- name: SearchTournamentsByStatus :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE deleted_at IS NULL AND status = $3::TEXT AND ( @@ -826,6 +837,7 @@ func (q *Queries) SearchTournamentsByStatus(ctx context.Context, arg SearchTourn &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -886,7 +898,7 @@ UPDATE tournaments SET td_user_id = COALESCE($24, td_user_id), updated_at = NOW() WHERE id = $25 AND deleted_at IS NULL -RETURNING id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at +RETURNING id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id ` type UpdateTournamentParams struct { @@ -977,6 +989,7 @@ func (q *Queries) UpdateTournament(ctx context.Context, arg UpdateTournamentPara &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } diff --git a/api/db/generated/uploads.sql.go b/api/db/generated/uploads.sql.go index c56fcb7..c548d8b 100644 --- a/api/db/generated/uploads.sql.go +++ b/api/db/generated/uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: uploads.sql package generated diff --git a/api/db/generated/users.sql.go b/api/db/generated/users.sql.go index 3b1fc54..ed2bf9c 100644 --- a/api/db/generated/users.sql.go +++ b/api/db/generated/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: users.sql package generated @@ -77,7 +77,7 @@ INSERT INTO users ( ) VALUES ( $1, $2, $3, '', 'player', 'unclaimed' ) -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type CreateUnclaimedUserParams struct { @@ -127,6 +127,7 @@ func (q *Queries) CreateUnclaimedUser(ctx context.Context, arg CreateUnclaimedUs &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -138,7 +139,7 @@ INSERT INTO users ( ) VALUES ( $1, $2, $3, $4, $5, $6, $7 ) -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type CreateUserParams struct { @@ -201,12 +202,94 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, + ) + return i, err +} + +const createUserFromLogto = `-- name: CreateUserFromLogto :one +INSERT INTO users ( + email, first_name, last_name, password_hash, date_of_birth, + logto_user_id, status, role +) +VALUES ( + $1::TEXT, $2::TEXT, $3::TEXT, '', + DATE '1900-01-01', + $4::TEXT, 'active', 'player' +) +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id +` + +type CreateUserFromLogtoParams struct { + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + LogtoUserID string `json:"logto_user_id"` +} + +// Used by webhooks/handler when Logto fires User.Created and we have +// no local mirror yet. password_hash is NOT NULL on the table; we +// write a sentinel ” string because Phase 6 will drop the column +// entirely. role defaults via column default ('player'). +// +// date_of_birth is NOT NULL on the legacy schema and Logto does not +// expose DOB on the user record. We seed with the SQL epoch sentinel +// '1900-01-01'; users provide their real DOB when they fill out the +// profile form (which writes to player_profiles.date_of_birth, the +// forward-looking home for that field after the Phase 6 cutover). +func (q *Queries) CreateUserFromLogto(ctx context.Context, arg CreateUserFromLogtoParams) (User, error) { + row := q.db.QueryRow(ctx, createUserFromLogto, + arg.Email, + arg.FirstName, + arg.LastName, + arg.LogtoUserID, + ) + var i User + err := row.Scan( + &i.ID, + &i.PublicID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.DateOfBirth, + &i.DisplayName, + &i.Status, + &i.MergedIntoID, + &i.Role, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Gender, + &i.Handedness, + &i.AvatarUrl, + &i.Bio, + &i.City, + &i.StateProvince, + &i.Country, + &i.Phone, + &i.PaddleBrand, + &i.PaddleModel, + &i.DuprID, + &i.VairID, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.IsProfileHidden, + &i.AddressLine1, + &i.AddressLine2, + &i.PostalCode, + &i.Latitude, + &i.Longitude, + &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE email = $1 AND deleted_at IS NULL ` @@ -251,12 +334,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email *string) (User, erro &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE id = $1 AND deleted_at IS NULL ` @@ -301,12 +385,71 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, + ) + return i, err +} + +const getUserByLogtoUserID = `-- name: GetUserByLogtoUserID :one + +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users +WHERE logto_user_id = $1 AND deleted_at IS NULL +` + +// ============================================================================ +// Logto integration (Phase 2 additive). The legacy queries above continue +// to function for the cookie-session code path; the queries below let +// new code resolve users by their Logto user ID without touching +// password_hash / role. +// ============================================================================ +func (q *Queries) GetUserByLogtoUserID(ctx context.Context, logtoUserID *string) (User, error) { + row := q.db.QueryRow(ctx, getUserByLogtoUserID, logtoUserID) + var i User + err := row.Scan( + &i.ID, + &i.PublicID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.DateOfBirth, + &i.DisplayName, + &i.Status, + &i.MergedIntoID, + &i.Role, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Gender, + &i.Handedness, + &i.AvatarUrl, + &i.Bio, + &i.City, + &i.StateProvince, + &i.Country, + &i.Phone, + &i.PaddleBrand, + &i.PaddleModel, + &i.DuprID, + &i.VairID, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.IsProfileHidden, + &i.AddressLine1, + &i.AddressLine2, + &i.PostalCode, + &i.Latitude, + &i.Longitude, + &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getUserByPublicID = `-- name: GetUserByPublicID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE public_id = $1 AND deleted_at IS NULL ` @@ -351,12 +494,13 @@ func (q *Queries) GetUserByPublicID(ctx context.Context, publicID string) (User, &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const listUsers = `-- name: ListUsers :many -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -414,6 +558,7 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]User, e &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ); err != nil { return nil, err } @@ -426,7 +571,7 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]User, e } const searchUsers = `-- name: SearchUsers :many -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE deleted_at IS NULL AND ( $3::TEXT IS NULL @@ -502,6 +647,7 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Use &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ); err != nil { return nil, err } @@ -513,6 +659,83 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Use return items, nil } +const setUserLogtoUserID = `-- name: SetUserLogtoUserID :one +UPDATE users SET + logto_user_id = $2::TEXT, + updated_at = now() +WHERE id = $1 + AND deleted_at IS NULL + AND (logto_user_id IS NULL OR logto_user_id = $2::TEXT) +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id +` + +type SetUserLogtoUserIDParams struct { + ID int64 `json:"id"` + LogtoUserID string `json:"logto_user_id"` +} + +// Bind a Logto user ID to an existing local mirror row. +// +// Behavior is "bind once, never change": +// - Setting a value to the same logto_user_id is a no-op success +// (idempotent). +// - Setting a different value when one is already bound returns 0 +// rows affected (the WHERE filters it out); the caller surfaces +// this as a 409 Conflict. +// - The UNIQUE partial index idx_users_logto_user_id additionally +// enforces that the same logto_user_id cannot bind to two +// different local user rows. +// +// The non-pointer @logto_user_id parameter (string, not *string) +// prevents accidental NULL writes from a buggy caller. If a future +// flow needs to clear a binding (e.g. account deletion), add a +// separate ClearUserLogtoUserID query rather than reusing this one. +func (q *Queries) SetUserLogtoUserID(ctx context.Context, arg SetUserLogtoUserIDParams) (User, error) { + row := q.db.QueryRow(ctx, setUserLogtoUserID, arg.ID, arg.LogtoUserID) + var i User + err := row.Scan( + &i.ID, + &i.PublicID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.DateOfBirth, + &i.DisplayName, + &i.Status, + &i.MergedIntoID, + &i.Role, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Gender, + &i.Handedness, + &i.AvatarUrl, + &i.Bio, + &i.City, + &i.StateProvince, + &i.Country, + &i.Phone, + &i.PaddleBrand, + &i.PaddleModel, + &i.DuprID, + &i.VairID, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.IsProfileHidden, + &i.AddressLine1, + &i.AddressLine2, + &i.PostalCode, + &i.Latitude, + &i.Longitude, + &i.FormattedAddress, + &i.LogtoUserID, + ) + return i, err +} + const softDeleteUser = `-- name: SoftDeleteUser :exec UPDATE users SET deleted_at = now(), @@ -525,6 +748,27 @@ func (q *Queries) SoftDeleteUser(ctx context.Context, id int64) error { return err } +const softDeleteUserByLogtoUserID = `-- name: SoftDeleteUserByLogtoUserID :exec +UPDATE users +SET deleted_at = now(), + updated_at = now() +WHERE logto_user_id = $1::TEXT + AND deleted_at IS NULL +` + +// Used by webhooks for User.Deleted. Sets deleted_at; leaves the row +// so referential integrity (e.g. tournaments.created_by) survives. +// +// We deliberately do NOT change users.status here: the legacy CHECK +// constraint accepts only ('active','suspended','banned','unclaimed', +// 'merged'); soft-delete state is conveyed by deleted_at IS NOT NULL, +// which every existing query already filters on. This matches the +// pattern of SoftDeleteUser above. +func (q *Queries) SoftDeleteUserByLogtoUserID(ctx context.Context, logtoUserID string) error { + _, err := q.db.Exec(ctx, softDeleteUserByLogtoUserID, logtoUserID) + return err +} + const updateUser = `-- name: UpdateUser :one UPDATE users SET first_name = COALESCE($2, first_name), @@ -532,7 +776,7 @@ UPDATE users SET display_name = COALESCE($4, display_name), updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdateUserParams struct { @@ -588,6 +832,72 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, e &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, + ) + return i, err +} + +const updateUserFromLogto = `-- name: UpdateUserFromLogto :one +UPDATE users +SET + email = $1::TEXT, + display_name = $2, + updated_at = now() +WHERE id = $3 +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id +` + +type UpdateUserFromLogtoParams struct { + Email string `json:"email"` + DisplayName *string `json:"display_name"` + ID int64 `json:"id"` +} + +// Used by webhooks for User.Data.Updated. Updates email + display_name +// (synthesizes from name) only. first_name/last_name stay as set at +// creation; if Logto's name changes, the human re-edits here. +func (q *Queries) UpdateUserFromLogto(ctx context.Context, arg UpdateUserFromLogtoParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserFromLogto, arg.Email, arg.DisplayName, arg.ID) + var i User + err := row.Scan( + &i.ID, + &i.PublicID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.DateOfBirth, + &i.DisplayName, + &i.Status, + &i.MergedIntoID, + &i.Role, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Gender, + &i.Handedness, + &i.AvatarUrl, + &i.Bio, + &i.City, + &i.StateProvince, + &i.Country, + &i.Phone, + &i.PaddleBrand, + &i.PaddleModel, + &i.DuprID, + &i.VairID, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.IsProfileHidden, + &i.AddressLine1, + &i.AddressLine2, + &i.PostalCode, + &i.Latitude, + &i.Longitude, + &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -596,7 +906,7 @@ const updateUserPassword = `-- name: UpdateUserPassword :one UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1 -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdateUserPasswordParams struct { @@ -645,6 +955,7 @@ func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPassword &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -654,7 +965,7 @@ UPDATE users SET role = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdateUserRoleParams struct { @@ -703,6 +1014,7 @@ func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -712,7 +1024,7 @@ UPDATE users SET status = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdateUserStatusParams struct { @@ -761,6 +1073,7 @@ func (q *Queries) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusPara &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } diff --git a/api/db/generated/venue_managers.sql.go b/api/db/generated/venue_managers.sql.go index d2ee73d..633b3ca 100644 --- a/api/db/generated/venue_managers.sql.go +++ b/api/db/generated/venue_managers.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: venue_managers.sql package generated @@ -174,7 +174,7 @@ func (q *Queries) ListVenueManagers(ctx context.Context, venueID int64) ([]ListV } const listVenuesByManager = `-- name: ListVenuesByManager :many -SELECT v.id, v.name, v.slug, v.status, v.address_line_1, v.address_line_2, v.city, v.state_province, v.country, v.postal_code, v.latitude, v.longitude, v.timezone, v.website_url, v.contact_email, v.contact_phone, v.logo_url, v.photo_url, v.venue_map_url, v.description, v.surface_types, v.amenities, v.org_id, v.managed_by_user_id, v.bio, v.notes, v.created_by_user_id, v.created_at, v.updated_at, v.deleted_at, v.formatted_address +SELECT v.id, v.name, v.slug, v.status, v.address_line_1, v.address_line_2, v.city, v.state_province, v.country, v.postal_code, v.latitude, v.longitude, v.timezone, v.website_url, v.contact_email, v.contact_phone, v.logo_url, v.photo_url, v.venue_map_url, v.description, v.surface_types, v.amenities, v.org_id, v.managed_by_user_id, v.bio, v.notes, v.created_by_user_id, v.created_at, v.updated_at, v.deleted_at, v.formatted_address, v.sport_id FROM venues v JOIN venue_managers vm ON vm.venue_id = v.id WHERE vm.user_id = $1 AND v.deleted_at IS NULL @@ -222,6 +222,7 @@ func (q *Queries) ListVenuesByManager(ctx context.Context, userID int64) ([]Venu &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } diff --git a/api/db/generated/venues.sql.go b/api/db/generated/venues.sql.go index a431824..aad6a09 100644 --- a/api/db/generated/venues.sql.go +++ b/api/db/generated/venues.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: venues.sql package generated @@ -97,7 +97,7 @@ INSERT INTO venues ( $20, $21, $22, $23, $24, $25, $26, $27 ) -RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address +RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id ` type CreateVenueParams struct { @@ -193,12 +193,13 @@ func (q *Queries) CreateVenue(ctx context.Context, arg CreateVenueParams) (Venue &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } const getVenueByID = `-- name: GetVenueByID :one -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE id = $1 AND deleted_at IS NULL ` @@ -237,12 +238,13 @@ func (q *Queries) GetVenueByID(ctx context.Context, id int64) (Venue, error) { &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } const getVenueBySlug = `-- name: GetVenueBySlug :one -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE slug = $1 AND deleted_at IS NULL ` @@ -281,6 +283,7 @@ func (q *Queries) GetVenueBySlug(ctx context.Context, slug string) (Venue, error &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } @@ -298,7 +301,7 @@ func (q *Queries) GetVenueCourtCount(ctx context.Context, venueID pgtype.Int8) ( } const listPendingVenues = `-- name: ListPendingVenues :many -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE status = 'pending_review' AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $1 OFFSET $2 @@ -350,6 +353,7 @@ func (q *Queries) ListPendingVenues(ctx context.Context, arg ListPendingVenuesPa &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -362,7 +366,7 @@ func (q *Queries) ListPendingVenues(ctx context.Context, arg ListPendingVenuesPa } const listVenues = `-- name: ListVenues :many -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE deleted_at IS NULL AND ($3::TEXT IS NULL OR status = $3::TEXT) ORDER BY name @@ -416,6 +420,7 @@ func (q *Queries) ListVenues(ctx context.Context, arg ListVenuesParams) ([]Venue &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -428,7 +433,7 @@ func (q *Queries) ListVenues(ctx context.Context, arg ListVenuesParams) ([]Venue } const searchVenues = `-- name: SearchVenues :many -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE deleted_at IS NULL AND ($3::TEXT IS NULL OR status = $3::TEXT) AND ( @@ -502,6 +507,7 @@ func (q *Queries) SearchVenues(ctx context.Context, arg SearchVenuesParams) ([]V &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -553,7 +559,7 @@ UPDATE venues SET notes = COALESCE($24, notes), updated_at = now() WHERE id = $25 AND deleted_at IS NULL -RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address +RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id ` type UpdateVenueParams struct { @@ -645,6 +651,7 @@ func (q *Queries) UpdateVenue(ctx context.Context, arg UpdateVenueParams) (Venue &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } @@ -654,7 +661,7 @@ UPDATE venues SET status = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address +RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id ` type UpdateVenueStatusParams struct { @@ -697,6 +704,7 @@ func (q *Queries) UpdateVenueStatus(ctx context.Context, arg UpdateVenueStatusPa &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } diff --git a/api/db/migrations/00041_logto_schema_additive.sql b/api/db/migrations/00041_logto_schema_additive.sql new file mode 100644 index 0000000..1066a1d --- /dev/null +++ b/api/db/migrations/00041_logto_schema_additive.sql @@ -0,0 +1,198 @@ +-- +goose Up + +-- ============================================================================ +-- Phase 2 of the Logto integration: ADDITIVE schema changes. +-- +-- This migration only ADDS tables, columns, and indexes. It deliberately +-- does not drop any existing schema; that work is deferred to a Phase 6 +-- cutover migration once all Go callers have stopped reading the +-- to-be-removed columns (password_hash, role, raw_password, +-- key_hash, etc.). +-- +-- See docs/DATABASE_OWNERSHIP.md for which fields live where after this +-- migration lands. See +-- docs/superpowers/specs/2026-04-20-logto-integration-design.md for the +-- broader design and +-- docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md for the +-- expanded plan this migration implements. +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 1. sports lookup table +-- --------------------------------------------------------------------------- + +CREATE TABLE sports ( + id BIGSERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + logto_org_id TEXT NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_sports_active ON sports(sort_order) WHERE is_active = true; + +-- Seed both sports. The logto_org_id values below are placeholders from +-- the original development tenant; they are stale on every other Logto +-- tenant because Logto generates random org IDs at creation time. +-- +-- Migration 00042 rewrites these specific values to 'pending-seed' so +-- the api's startup verification (api/startup/verify_sports.go) refuses +-- to boot in production until the bootstrap seeder +-- (api/cmd/logto-seed/main.go -> syncSportsOrgIDs) has replaced them +-- with the real org IDs from this tenant. +-- +-- DO NOT add new sports here with hardcoded org IDs -- the seeder is +-- the single source of truth. +INSERT INTO sports (slug, name, logto_org_id, sort_order) VALUES + ('pickleball', 'Pickleball', 'ekup1zyrrxj4', 1), + ('demo_sport', 'Demo Sport', '7866ex96uk6b', 99); + +-- --------------------------------------------------------------------------- +-- 2. users.logto_user_id (nullable mirror FK to Logto's user record) +-- --------------------------------------------------------------------------- + +-- Nullable for now: existing rows have no Logto identity, and the +-- Phase 5 webhook + Phase 6 cutover will populate it. NOT NULL is +-- imposed in the cutover migration once every code path is rewritten +-- to either bind a logto_user_id at create time or upsert on first +-- authenticated request. +ALTER TABLE users ADD COLUMN logto_user_id TEXT; + +-- Partial UNIQUE index allows multiple NULLs (legacy rows) but enforces +-- one-to-one mapping for any row that does have a Logto user. +CREATE UNIQUE INDEX idx_users_logto_user_id + ON users(logto_user_id) + WHERE logto_user_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 3. player_profiles (initially empty; populated by Phase 3 forms) +-- --------------------------------------------------------------------------- + +-- 1:1 with users by user_id. Lives separately so the users table can +-- shrink to a Logto-mirror shape in Phase 6 without losing profile +-- data. Until Phase 3 starts writing here, every row in users has an +-- implicit empty profile (queries treat absence as defaults). +CREATE TABLE player_profiles ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + phone TEXT, + dupr_id TEXT, + vair_id TEXT, + paddle_brand TEXT, + paddle_model TEXT, + gender TEXT CHECK (gender IN ('male','female','non_binary','prefer_not_to_say')), + handedness TEXT CHECK (handedness IN ('right','left','ambidextrous')), + date_of_birth DATE, + bio TEXT, + address_line_1 TEXT, + address_line_2 TEXT, + city TEXT, + state_province TEXT, + country TEXT, + postal_code TEXT, + formatted_address TEXT, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + emergency_contact_name TEXT, + emergency_contact_phone TEXT, + medical_notes TEXT, + waiver_accepted_at TIMESTAMPTZ, + avatar_url TEXT, + is_profile_hidden BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_player_profiles_dupr_id + ON player_profiles(dupr_id) WHERE dupr_id IS NOT NULL; +CREATE INDEX idx_player_profiles_vair_id + ON player_profiles(vair_id) WHERE vair_id IS NOT NULL; +CREATE INDEX idx_player_profiles_city_state + ON player_profiles(city, state_province); + +-- --------------------------------------------------------------------------- +-- 4. sport_id columns on top-level domain tables +-- --------------------------------------------------------------------------- + +-- All five columns are nullable for now and backfilled to Pickleball. +-- Phase 6 cutover migration imposes NOT NULL after every Go writer has +-- been updated to set sport_id explicitly. +-- +-- Each backfill is written as a self-contained UPDATE that re-resolves +-- the Pickleball sport id rather than using a DO/PLPGSQL block (which +-- would require goose StatementBegin/End markers and is unnecessary +-- here -- the resolution is cheap and only runs at migration time). + +ALTER TABLE tournaments ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE tournaments SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; + +ALTER TABLE leagues ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE leagues SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; + +ALTER TABLE organizations ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE organizations SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; + +ALTER TABLE venues ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE venues SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; + +ALTER TABLE divisions ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE divisions SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; + +CREATE INDEX idx_tournaments_sport + ON tournaments(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_leagues_sport + ON leagues(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_organizations_sport + ON organizations(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_venues_sport + ON venues(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_divisions_sport + ON divisions(sport_id) WHERE deleted_at IS NULL; + +-- --------------------------------------------------------------------------- +-- 5. api_keys.logto_m2m_app_id (nullable mirror FK to Logto M2M app) +-- --------------------------------------------------------------------------- + +ALTER TABLE api_keys ADD COLUMN logto_m2m_app_id TEXT; + +CREATE UNIQUE INDEX idx_api_keys_logto_m2m_app_id + ON api_keys(logto_m2m_app_id) + WHERE logto_m2m_app_id IS NOT NULL; + + +-- +goose Down + +-- Reverse in opposite order. All operations are conditional so a +-- partial-up migration can be rolled back cleanly. + +DROP INDEX IF EXISTS idx_api_keys_logto_m2m_app_id; +ALTER TABLE api_keys DROP COLUMN IF EXISTS logto_m2m_app_id; + +DROP INDEX IF EXISTS idx_divisions_sport; +DROP INDEX IF EXISTS idx_venues_sport; +DROP INDEX IF EXISTS idx_organizations_sport; +DROP INDEX IF EXISTS idx_leagues_sport; +DROP INDEX IF EXISTS idx_tournaments_sport; + +ALTER TABLE divisions DROP COLUMN IF EXISTS sport_id; +ALTER TABLE venues DROP COLUMN IF EXISTS sport_id; +ALTER TABLE organizations DROP COLUMN IF EXISTS sport_id; +ALTER TABLE leagues DROP COLUMN IF EXISTS sport_id; +ALTER TABLE tournaments DROP COLUMN IF EXISTS sport_id; + +DROP INDEX IF EXISTS idx_player_profiles_city_state; +DROP INDEX IF EXISTS idx_player_profiles_vair_id; +DROP INDEX IF EXISTS idx_player_profiles_dupr_id; +DROP TABLE IF EXISTS player_profiles; + +DROP INDEX IF EXISTS idx_users_logto_user_id; +ALTER TABLE users DROP COLUMN IF EXISTS logto_user_id; + +DROP INDEX IF EXISTS idx_sports_active; +DROP TABLE IF EXISTS sports; diff --git a/api/db/migrations/00042_sports_logto_org_id_pending.sql b/api/db/migrations/00042_sports_logto_org_id_pending.sql new file mode 100644 index 0000000..2fba7fc --- /dev/null +++ b/api/db/migrations/00042_sports_logto_org_id_pending.sql @@ -0,0 +1,44 @@ +-- +goose Up + +-- ============================================================================ +-- Replace migration 00041's hardcoded Logto org IDs with a 'pending-seed' +-- placeholder on any environment that still has them. +-- +-- WHY: Logto org IDs are randomly generated when an org is created, so the +-- IDs that were correct for the developer who wrote 00041 (Pickleball = +-- 'ekup1zyrrxj4', Demo Sport = '7866ex96uk6b') are stale on every fresh +-- Logto tenant. The SPA requests org-scoped tokens via +-- getAccessToken(resource, sport.logto_org_id); when that org ID doesn't +-- exist on the tenant, Logto silently issues a resource-only token instead +-- of failing. The token then has no `organization_roles` claim, the api's +-- claims.ElevatedRole() returns "", and the user sees their underlying DB +-- role ('player') with no admin link in the sidebar -- even when they have +-- platform_admin assigned in Logto Console. +-- +-- WHAT THIS DOES: Only rewrites rows that still hold the original 00041 +-- hardcoded values. Any environment that has already run the bootstrap +-- seeder (api/cmd/logto-seed) -- which calls syncSportsOrgIDs to push the +-- real Logto IDs into this table -- has different values in those columns +-- and is left untouched. After this migration, fresh installs see +-- 'pending-seed' and the api's startup verification (api/startup/ +-- verify_sports.go) refuses to boot in production until the seeder has run +-- and replaced the placeholders with real Logto org IDs. +-- ============================================================================ + +-- Per-slug placeholder values keep the UNIQUE(logto_org_id) constraint +-- happy. The api's startup verifier (api/startup/verify_sports.go) and +-- the auto-bootstrap seeder (api/logtoseed.Run) both treat any value +-- with the 'pending-seed:' prefix as "needs replacement"; the seeder +-- overwrites these with the real Logto org IDs on the next boot. +UPDATE sports SET logto_org_id = 'pending-seed:pickleball' +WHERE slug = 'pickleball' AND logto_org_id = 'ekup1zyrrxj4'; + +UPDATE sports SET logto_org_id = 'pending-seed:demo_sport' +WHERE slug = 'demo_sport' AND logto_org_id = '7866ex96uk6b'; + +-- +goose Down + +-- Restoring stale IDs would be actively harmful (they don't exist on any +-- real Logto tenant). Down is intentionally a no-op; rolling back this +-- migration leaves the columns at whatever value the seeder put there. +SELECT 1; diff --git a/api/db/migrations/00043_match_events_verbal_calls.sql b/api/db/migrations/00043_match_events_verbal_calls.sql new file mode 100644 index 0000000..1292bf0 --- /dev/null +++ b/api/db/migrations/00043_match_events_verbal_calls.sql @@ -0,0 +1,95 @@ +-- +goose Up +-- PR-13 (smoke 11.5): The referee console can now record verbal officiating +-- calls (let / re-do / fault / line call) as match events that annotate the +-- timeline without mutating the score. The match_events.event_type CHECK +-- constraint (last set in 00031) accepted legacy 'fault' but not 'let', +-- 're_do', or 'line_call', so recording those rulings would explode at the +-- database with a generic 500. This migration widens the CHECK to include the +-- canonical verbal-call types defined in api/service/events.go (EventTypeLet, +-- EventTypeReDo, EventTypeFault, EventTypeLineCall) while retaining every +-- previously-accepted value for backward compatibility with existing rows. +ALTER TABLE match_events DROP CONSTRAINT IF EXISTS match_events_event_type_check; + +ALTER TABLE match_events ADD CONSTRAINT match_events_event_type_check + CHECK (event_type IN ( + -- Canonical CR-1 event types (see api/service/events.go). + 'match_started', + 'match_paused', + 'match_resumed', + 'match_complete', + 'match_reset', + 'point_team1', + 'point_team2', + 'point_removed', + 'side_out', + 'undo', + 'game_complete', + 'confirm_game_over', + 'confirm_match_over', + 'timeout', + 'timeout_ended', + 'end_change', + 'substitution', + 'match_configured', + 'score_override', + 'forfeit_declared', + + -- Officiating / verbal calls (PR-13, see api/service/events.go). + 'let', + 're_do', + 'fault', + 'line_call', + + -- Legacy values still accepted for historical rows created before CR-1. + 'timeout_team1', + 'timeout_team2', + 'end_timeout', + 'start_set', + 'end_set', + 'start_game', + 'end_game', + 'challenge', + 'note', + 'custom' + )); + +-- +goose Down +ALTER TABLE match_events DROP CONSTRAINT IF EXISTS match_events_event_type_check; + +ALTER TABLE match_events ADD CONSTRAINT match_events_event_type_check + CHECK (event_type IN ( + -- Canonical CR-1 event types (see api/service/events.go). + 'match_started', + 'match_paused', + 'match_resumed', + 'match_complete', + 'match_reset', + 'point_team1', + 'point_team2', + 'point_removed', + 'side_out', + 'undo', + 'game_complete', + 'confirm_game_over', + 'confirm_match_over', + 'timeout', + 'timeout_ended', + 'end_change', + 'substitution', + 'match_configured', + 'score_override', + 'forfeit_declared', + + -- Legacy values still accepted for historical rows created before CR-1. + 'timeout_team1', + 'timeout_team2', + 'end_timeout', + 'start_set', + 'end_set', + 'start_game', + 'end_game', + 'challenge', + 'fault', + 'note', + 'custom' + )); diff --git a/api/db/queries/api_keys.sql b/api/db/queries/api_keys.sql index 1a73651..f9ec51a 100644 --- a/api/db/queries/api_keys.sql +++ b/api/db/queries/api_keys.sql @@ -26,3 +26,30 @@ WHERE id = $1 AND user_id = $2; -- name: CountApiKeysByUser :one SELECT count(*) FROM api_keys WHERE user_id = $1 AND is_active = true; + +-- ============================================================================ +-- Logto M2M integration (Phase 2 additive). Phase 4 will rewrite +-- CreateApiKey to delegate to logto.CreateM2MApp and persist the +-- mirror row with logto_m2m_app_id; until then the legacy bcrypt +-- key path keeps working. +-- ============================================================================ + +-- name: GetAPIKeyByLogtoM2MAppID :one +-- Admin / management lookup by Logto M2M app ID. Does NOT filter on +-- is_active because admin tools (Phase 4 webhook handlers, deactivation +-- flows, audit trails) need to find rows regardless of state. The hot +-- request-auth path uses GetActiveAPIKeyByLogtoM2MAppID instead. +SELECT * FROM api_keys WHERE logto_m2m_app_id = $1; + +-- name: GetActiveAPIKeyByLogtoM2MAppID :one +-- Request-auth lookup: only returns the row if it is active. Mirrors +-- the GetApiKeyByHash semantics on the legacy code path. +SELECT * FROM api_keys +WHERE logto_m2m_app_id = $1 AND is_active = true; + +-- name: SetAPIKeyLogtoM2MAppID :one +UPDATE api_keys SET + logto_m2m_app_id = $2, + updated_at = now() +WHERE id = $1 +RETURNING *; diff --git a/api/db/queries/player_profiles.sql b/api/db/queries/player_profiles.sql new file mode 100644 index 0000000..8c630e1 --- /dev/null +++ b/api/db/queries/player_profiles.sql @@ -0,0 +1,75 @@ +-- api/db/queries/player_profiles.sql + +-- name: GetPlayerProfileRow :one +-- Note: named with the 'Row' suffix because the legacy +-- api/db/queries/players.sql still has a GetPlayerProfile that +-- queries the users table. Phase 6 cutover removes that query and +-- this one becomes the canonical lookup. +SELECT * FROM player_profiles WHERE user_id = $1; + +-- name: UpsertPlayerProfile :one +-- Insert or partial-update the 1:1 profile row. Every field is optional +-- (sqlc.narg). On INSERT, NULL nargs become NULL in the new row. On +-- UPDATE, NULL nargs leave the existing column value unchanged via +-- COALESCE. is_profile_hidden defaults to false on insert. +-- +-- This shape mirrors the existing players.sql:UpdatePlayerProfile narg +-- pattern, so a Phase 3 profile-edit form that sends only the fields +-- the user actually changed will not blow away the rest. To explicitly +-- clear a field, pass an empty-string-coerced override at the call +-- site or add a dedicated ClearPlayerProfileField query. +INSERT INTO player_profiles ( + user_id, + phone, dupr_id, vair_id, paddle_brand, paddle_model, + gender, handedness, date_of_birth, bio, + address_line_1, address_line_2, city, state_province, country, + postal_code, formatted_address, latitude, longitude, + emergency_contact_name, emergency_contact_phone, medical_notes, + waiver_accepted_at, avatar_url, is_profile_hidden, + updated_at +) VALUES ( + @user_id::BIGINT, + sqlc.narg('phone'), sqlc.narg('dupr_id'), sqlc.narg('vair_id'), + sqlc.narg('paddle_brand'), sqlc.narg('paddle_model'), + sqlc.narg('gender'), sqlc.narg('handedness'), + sqlc.narg('date_of_birth'), sqlc.narg('bio'), + sqlc.narg('address_line_1'), sqlc.narg('address_line_2'), + sqlc.narg('city'), sqlc.narg('state_province'), sqlc.narg('country'), + sqlc.narg('postal_code'), sqlc.narg('formatted_address'), + sqlc.narg('latitude'), sqlc.narg('longitude'), + sqlc.narg('emergency_contact_name'), sqlc.narg('emergency_contact_phone'), + sqlc.narg('medical_notes'), + sqlc.narg('waiver_accepted_at'), sqlc.narg('avatar_url'), + COALESCE(sqlc.narg('is_profile_hidden')::BOOLEAN, false), + now() +) +ON CONFLICT (user_id) DO UPDATE SET + phone = COALESCE(sqlc.narg('phone'), player_profiles.phone), + dupr_id = COALESCE(sqlc.narg('dupr_id'), player_profiles.dupr_id), + vair_id = COALESCE(sqlc.narg('vair_id'), player_profiles.vair_id), + paddle_brand = COALESCE(sqlc.narg('paddle_brand'), player_profiles.paddle_brand), + paddle_model = COALESCE(sqlc.narg('paddle_model'), player_profiles.paddle_model), + gender = COALESCE(sqlc.narg('gender'), player_profiles.gender), + handedness = COALESCE(sqlc.narg('handedness'), player_profiles.handedness), + date_of_birth = COALESCE(sqlc.narg('date_of_birth'), player_profiles.date_of_birth), + bio = COALESCE(sqlc.narg('bio'), player_profiles.bio), + address_line_1 = COALESCE(sqlc.narg('address_line_1'), player_profiles.address_line_1), + address_line_2 = COALESCE(sqlc.narg('address_line_2'), player_profiles.address_line_2), + city = COALESCE(sqlc.narg('city'), player_profiles.city), + state_province = COALESCE(sqlc.narg('state_province'), player_profiles.state_province), + country = COALESCE(sqlc.narg('country'), player_profiles.country), + postal_code = COALESCE(sqlc.narg('postal_code'), player_profiles.postal_code), + formatted_address = COALESCE(sqlc.narg('formatted_address'), player_profiles.formatted_address), + latitude = COALESCE(sqlc.narg('latitude'), player_profiles.latitude), + longitude = COALESCE(sqlc.narg('longitude'), player_profiles.longitude), + emergency_contact_name = COALESCE(sqlc.narg('emergency_contact_name'), player_profiles.emergency_contact_name), + emergency_contact_phone = COALESCE(sqlc.narg('emergency_contact_phone'), player_profiles.emergency_contact_phone), + medical_notes = COALESCE(sqlc.narg('medical_notes'), player_profiles.medical_notes), + waiver_accepted_at = COALESCE(sqlc.narg('waiver_accepted_at'), player_profiles.waiver_accepted_at), + avatar_url = COALESCE(sqlc.narg('avatar_url'), player_profiles.avatar_url), + is_profile_hidden = COALESCE(sqlc.narg('is_profile_hidden'), player_profiles.is_profile_hidden), + updated_at = now() +RETURNING *; + +-- name: DeletePlayerProfile :exec +DELETE FROM player_profiles WHERE user_id = $1; diff --git a/api/db/queries/sports.sql b/api/db/queries/sports.sql new file mode 100644 index 0000000..36174e7 --- /dev/null +++ b/api/db/queries/sports.sql @@ -0,0 +1,15 @@ +-- api/db/queries/sports.sql + +-- name: ListSports :many +SELECT * FROM sports +WHERE is_active = true +ORDER BY sort_order, name; + +-- name: GetSportByID :one +SELECT * FROM sports WHERE id = $1; + +-- name: GetSportBySlug :one +SELECT * FROM sports WHERE slug = $1; + +-- name: GetSportByLogtoOrgID :one +SELECT * FROM sports WHERE logto_org_id = $1; diff --git a/api/db/queries/users.sql b/api/db/queries/users.sql index fd8477f..febdd7b 100644 --- a/api/db/queries/users.sql +++ b/api/db/queries/users.sql @@ -108,3 +108,88 @@ UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1 RETURNING *; + +-- ============================================================================ +-- Logto integration (Phase 2 additive). The legacy queries above continue +-- to function for the cookie-session code path; the queries below let +-- new code resolve users by their Logto user ID without touching +-- password_hash / role. +-- ============================================================================ + +-- name: GetUserByLogtoUserID :one +SELECT * FROM users +WHERE logto_user_id = $1 AND deleted_at IS NULL; + +-- name: SetUserLogtoUserID :one +-- Bind a Logto user ID to an existing local mirror row. +-- +-- Behavior is "bind once, never change": +-- - Setting a value to the same logto_user_id is a no-op success +-- (idempotent). +-- - Setting a different value when one is already bound returns 0 +-- rows affected (the WHERE filters it out); the caller surfaces +-- this as a 409 Conflict. +-- - The UNIQUE partial index idx_users_logto_user_id additionally +-- enforces that the same logto_user_id cannot bind to two +-- different local user rows. +-- +-- The non-pointer @logto_user_id parameter (string, not *string) +-- prevents accidental NULL writes from a buggy caller. If a future +-- flow needs to clear a binding (e.g. account deletion), add a +-- separate ClearUserLogtoUserID query rather than reusing this one. +UPDATE users SET + logto_user_id = @logto_user_id::TEXT, + updated_at = now() +WHERE id = $1 + AND deleted_at IS NULL + AND (logto_user_id IS NULL OR logto_user_id = @logto_user_id::TEXT) +RETURNING *; + +-- name: CreateUserFromLogto :one +-- Used by webhooks/handler when Logto fires User.Created and we have +-- no local mirror yet. password_hash is NOT NULL on the table; we +-- write a sentinel '' string because Phase 6 will drop the column +-- entirely. role defaults via column default ('player'). +-- +-- date_of_birth is NOT NULL on the legacy schema and Logto does not +-- expose DOB on the user record. We seed with the SQL epoch sentinel +-- '1900-01-01'; users provide their real DOB when they fill out the +-- profile form (which writes to player_profiles.date_of_birth, the +-- forward-looking home for that field after the Phase 6 cutover). +INSERT INTO users ( + email, first_name, last_name, password_hash, date_of_birth, + logto_user_id, status, role +) +VALUES ( + @email::TEXT, @first_name::TEXT, @last_name::TEXT, '', + DATE '1900-01-01', + @logto_user_id::TEXT, 'active', 'player' +) +RETURNING *; + +-- name: UpdateUserFromLogto :one +-- Used by webhooks for User.Data.Updated. Updates email + display_name +-- (synthesizes from name) only. first_name/last_name stay as set at +-- creation; if Logto's name changes, the human re-edits here. +UPDATE users +SET + email = @email::TEXT, + display_name = sqlc.narg('display_name'), + updated_at = now() +WHERE id = @id +RETURNING *; + +-- name: SoftDeleteUserByLogtoUserID :exec +-- Used by webhooks for User.Deleted. Sets deleted_at; leaves the row +-- so referential integrity (e.g. tournaments.created_by) survives. +-- +-- We deliberately do NOT change users.status here: the legacy CHECK +-- constraint accepts only ('active','suspended','banned','unclaimed', +-- 'merged'); soft-delete state is conveyed by deleted_at IS NOT NULL, +-- which every existing query already filters on. This matches the +-- pattern of SoftDeleteUser above. +UPDATE users +SET deleted_at = now(), + updated_at = now() +WHERE logto_user_id = @logto_user_id::TEXT + AND deleted_at IS NULL; diff --git a/api/db/seed.sql b/api/db/seed.sql index 5d6a17c..3d8cbe0 100644 --- a/api/db/seed.sql +++ b/api/db/seed.sql @@ -1,13 +1,29 @@ -- Court Command v2 — Development Seed Data --- Run with: make seed --- Requires: migrations already applied, empty or safe-to-overwrite database --- Password for all test users: TestPass123! (bcrypt hash below) --- Daniel Velez password: PASSword123! - --- Bcrypt hash for "TestPass123!" --- Generated via: htpasswd -bnBC 10 "" TestPass123! | tr -d ':\n' | sed 's/$2y/$2a/' - --- Wipe all existing data for a clean seed (CASCADE handles FK ordering) +-- Run with: make seed (uses docker-compose.dev.yml) +-- Requires: migrations applied + Logto seeded (`make logto-seed`). +-- The bootstrap admin (admin@courtcommand.local) must exist +-- in the local users table, mirrored from Logto on first sign-in. +-- +-- Phase 3.6+ Logto-aware behavior: +-- - The bootstrap admin (mirrored from Logto, logto_user_id IS NOT NULL) +-- is preserved across re-seeds. Domain data uses their users.id as +-- created_by_user_id. +-- - Other seeded "users" are SHADOW PLAYERS with status='unclaimed' and +-- logto_user_id=NULL. They have names/emails/etc. so TDs can register +-- them in tournaments. When a real Logto user later signs up with +-- the same email, Phase 4+ claim logic will merge the rows. +-- - The non-admin staff (td1@, ref1@, etc.) stay status='active' so +-- role-gated UI workflows have staff to attach to tournaments. None +-- of these accounts can sign in via Logto -- they're domain fixtures. +-- +-- All seeded sport_id columns point at Pickleball (the only sport at +-- launch). Demo Sport, if present, gets no seeded fixtures. + +-- Wipe all existing DOMAIN data for a clean seed (CASCADE handles FK +-- ordering). DO NOT TRUNCATE users -- the bootstrap admin (mirrored +-- from Logto) must persist. Domain data we DO wipe will cascade-delete +-- shadow users via the foreign keys (e.g. team_rosters, registrations). +-- After the cascade, only the bootstrap admin remains in users. TRUNCATE TABLE activity_logs, ad_configs, @@ -38,13 +54,20 @@ TRUNCATE TABLE org_memberships, team_rosters, teams, - organizations, - users + organizations CASCADE; --- Reset sequences so IDs start fresh -ALTER SEQUENCE users_id_seq RESTART WITH 1; -ALTER SEQUENCE user_public_id_seq RESTART WITH 10000; +-- Wipe all NON-bootstrap users individually so the bootstrap admin row +-- (and its player_profiles cascade child) survives. Match by Logto +-- linkage rather than email, since the bootstrap admin email is +-- configurable but logto_user_id IS NOT NULL is the source of truth. +DELETE FROM users WHERE logto_user_id IS NULL; + +-- Reset sequences for tables we just truncated. users_id_seq and +-- user_public_id_seq are NOT reset (the bootstrap admin holds id=1 +-- and public_id='CC-10000' from being mirrored on first signin; the +-- shadow users that follow continue from whatever the sequences +-- have advanced to). ALTER SEQUENCE teams_id_seq RESTART WITH 1; ALTER SEQUENCE organizations_id_seq RESTART WITH 1; ALTER SEQUENCE venues_id_seq RESTART WITH 1; @@ -79,6 +102,9 @@ ALTER SEQUENCE venue_managers_id_seq RESTART WITH 1; DO $$ DECLARE + -- Pickleball sport id (Phase 2+ -- all seeded fixtures attach here) + pickleball_id BIGINT; + -- Admin / staff accounts admin_id BIGINT; td1_id BIGINT; @@ -143,17 +169,37 @@ DECLARE -- League registrations (2) lr1_id BIGINT; lr2_id BIGINT; - pw_hash TEXT := '$2a$10$/gWAyV6CPKiin37643WZ9etvC/Vg2S6/F3xftSbZwJ2DzSdUonMiS'; - daniel_pw TEXT := '$2a$10$PWhdJ2i9ZwvfYY8qlrjBBeADmp2y0Q2N8xE0dDT0gbuAc32.3TnWK'; + -- Sentinel password_hash for shadow users -- they cannot sign in via + -- Logto (logto_user_id IS NULL). Empty string matches the sentinel + -- the Phase 3.5 webhook handler uses for newly-mirrored users; the + -- column stays NOT NULL until Phase 6 cutover drops it. + pw_hash TEXT := ''; BEGIN -- ============================================================ --- 1. USERS (24 total: 1 admin, 2 TDs, 2 refs, 1 scorekeeper, 1 broadcast op, 1 daniel, 16 players) +-- 0. RESOLVE BOOTSTRAP ADMIN + PICKLEBALL SPORT ID -- ============================================================ +-- The bootstrap admin is mirrored from Logto (logto_user_id IS NOT NULL). +-- We look them up by Logto-linkage rather than email so the seed +-- survives a configurable LOGTO_BOOTSTRAP_EMAIL. +SELECT id INTO admin_id FROM users WHERE logto_user_id IS NOT NULL ORDER BY id LIMIT 1; +IF admin_id IS NULL THEN + RAISE EXCEPTION 'No Logto-linked admin user found. Run `make logto-seed` and sign in once before seeding domain data.'; +END IF; -INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'admin@courtcommand.com', pw_hash, 'Admin', 'User', '1990-01-15', 'platform_admin', 'active') -RETURNING id INTO admin_id; +SELECT id INTO pickleball_id FROM sports WHERE slug = 'pickleball'; +IF pickleball_id IS NULL THEN + RAISE EXCEPTION 'Sport `pickleball` not found in sports table. Migrations must run first.'; +END IF; + +-- ============================================================ +-- 1. USERS (23 shadow users: 2 TDs, 2 refs, 1 scorekeeper, 1 broadcast op, 1 daniel, 16 players) +-- ============================================================ +-- These are SHADOW PLAYERS / staff with logto_user_id=NULL. They cannot +-- sign in via Logto on their own. status='unclaimed' for players (the +-- explicit "TD-created" status); status='active' for staff so +-- role-gated workflows still work in dev. +-- The bootstrap admin (admin_id, resolved above) is reused as needed. INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status) VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'td1@courtcommand.com', pw_hash, 'Tournament', 'Director', '1985-06-20', 'tournament_director', 'active') @@ -179,44 +225,48 @@ INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_ VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'broadcast@courtcommand.com', pw_hash, 'Broadcast', 'Operator', '1993-07-04', 'broadcast_operator', 'active') RETURNING id INTO broadcast_id; --- Daniel Velez — permanent admin +-- Daniel Velez — shadow admin (cannot sign in via Logto on this row; +-- the real Daniel signs in with admin@courtcommand.local during dev, +-- which mirrors to admin_id above. This row exists so domain data +-- can attribute ownership/creation to "Daniel" without conflating +-- with the dev bootstrap admin). INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'daniel.f.velez@gmail.com', daniel_pw, 'Daniel', 'Velez', '1990-01-01', 'platform_admin', 'active') +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'daniel.f.velez@gmail.com', pw_hash, 'Daniel', 'Velez', '1990-01-01', 'platform_admin', 'unclaimed') RETURNING id INTO daniel_id; -- 16 Players (variety of cities, genders, handedness) INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'alex.j@demo.com', pw_hash, 'Alex', 'Johnson', '1995-04-12', 'player', 'active', 'male', 'right', 'Dallas', 'TX', 'US') RETURNING id INTO p1_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'alex.j@demo.com', pw_hash, 'Alex', 'Johnson', '1995-04-12', 'player', 'unclaimed', 'male', 'right', 'Dallas', 'TX', 'US') RETURNING id INTO p1_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'maria.g@demo.com', pw_hash, 'Maria', 'Garcia', '1993-08-25', 'player', 'active', 'female', 'right', 'Dallas', 'TX', 'US') RETURNING id INTO p2_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'maria.g@demo.com', pw_hash, 'Maria', 'Garcia', '1993-08-25', 'player', 'unclaimed', 'female', 'right', 'Dallas', 'TX', 'US') RETURNING id INTO p2_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'james.w@demo.com', pw_hash, 'James', 'Wilson', '1990-11-03', 'player', 'active', 'male', 'left', 'Fort Worth', 'TX', 'US') RETURNING id INTO p3_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'james.w@demo.com', pw_hash, 'James', 'Wilson', '1990-11-03', 'player', 'unclaimed', 'male', 'left', 'Fort Worth', 'TX', 'US') RETURNING id INTO p3_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'sarah.c@demo.com', pw_hash, 'Sarah', 'Chen', '1997-02-18', 'player', 'active', 'female', 'right', 'Fort Worth', 'TX', 'US') RETURNING id INTO p4_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'sarah.c@demo.com', pw_hash, 'Sarah', 'Chen', '1997-02-18', 'player', 'unclaimed', 'female', 'right', 'Fort Worth', 'TX', 'US') RETURNING id INTO p4_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'mike.b@demo.com', pw_hash, 'Mike', 'Brown', '1992-07-30', 'player', 'active', 'male', 'right', 'Austin', 'TX', 'US') RETURNING id INTO p5_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'mike.b@demo.com', pw_hash, 'Mike', 'Brown', '1992-07-30', 'player', 'unclaimed', 'male', 'right', 'Austin', 'TX', 'US') RETURNING id INTO p5_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'lisa.p@demo.com', pw_hash, 'Lisa', 'Park', '1996-12-05', 'player', 'active', 'female', 'left', 'Austin', 'TX', 'US') RETURNING id INTO p6_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'lisa.p@demo.com', pw_hash, 'Lisa', 'Park', '1996-12-05', 'player', 'unclaimed', 'female', 'left', 'Austin', 'TX', 'US') RETURNING id INTO p6_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'david.k@demo.com', pw_hash, 'David', 'Kim', '1994-09-14', 'player', 'active', 'male', 'right', 'Houston', 'TX', 'US') RETURNING id INTO p7_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'david.k@demo.com', pw_hash, 'David', 'Kim', '1994-09-14', 'player', 'unclaimed', 'male', 'right', 'Houston', 'TX', 'US') RETURNING id INTO p7_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'emma.d@demo.com', pw_hash, 'Emma', 'Davis', '1998-05-22', 'player', 'active', 'female', 'right', 'Houston', 'TX', 'US') RETURNING id INTO p8_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'emma.d@demo.com', pw_hash, 'Emma', 'Davis', '1998-05-22', 'player', 'unclaimed', 'female', 'right', 'Houston', 'TX', 'US') RETURNING id INTO p8_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'carlos.r@demo.com', pw_hash, 'Carlos', 'Rodriguez', '1991-03-08', 'player', 'active', 'male', 'right', 'San Antonio', 'TX', 'US') RETURNING id INTO p9_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'carlos.r@demo.com', pw_hash, 'Carlos', 'Rodriguez', '1991-03-08', 'player', 'unclaimed', 'male', 'right', 'San Antonio', 'TX', 'US') RETURNING id INTO p9_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'jenny.l@demo.com', pw_hash, 'Jenny', 'Lee', '1996-07-19', 'player', 'active', 'female', 'right', 'San Antonio', 'TX', 'US') RETURNING id INTO p10_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'jenny.l@demo.com', pw_hash, 'Jenny', 'Lee', '1996-07-19', 'player', 'unclaimed', 'female', 'right', 'San Antonio', 'TX', 'US') RETURNING id INTO p10_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'kevin.m@demo.com', pw_hash, 'Kevin', 'Martinez', '1989-12-01', 'player', 'active', 'male', 'left', 'El Paso', 'TX', 'US') RETURNING id INTO p11_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'kevin.m@demo.com', pw_hash, 'Kevin', 'Martinez', '1989-12-01', 'player', 'unclaimed', 'male', 'left', 'El Paso', 'TX', 'US') RETURNING id INTO p11_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'amy.t@demo.com', pw_hash, 'Amy', 'Taylor', '1994-05-15', 'player', 'active', 'female', 'right', 'El Paso', 'TX', 'US') RETURNING id INTO p12_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'amy.t@demo.com', pw_hash, 'Amy', 'Taylor', '1994-05-15', 'player', 'unclaimed', 'female', 'right', 'El Paso', 'TX', 'US') RETURNING id INTO p12_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'brandon.h@demo.com', pw_hash, 'Brandon', 'Harris', '1993-10-22', 'player', 'active', 'male', 'right', 'Plano', 'TX', 'US') RETURNING id INTO p13_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'brandon.h@demo.com', pw_hash, 'Brandon', 'Harris', '1993-10-22', 'player', 'unclaimed', 'male', 'right', 'Plano', 'TX', 'US') RETURNING id INTO p13_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'rachel.w@demo.com', pw_hash, 'Rachel', 'White', '1997-08-30', 'player', 'active', 'female', 'right', 'Plano', 'TX', 'US') RETURNING id INTO p14_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'rachel.w@demo.com', pw_hash, 'Rachel', 'White', '1997-08-30', 'player', 'unclaimed', 'female', 'right', 'Plano', 'TX', 'US') RETURNING id INTO p14_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'tyler.n@demo.com', pw_hash, 'Tyler', 'Nguyen', '1990-06-14', 'player', 'active', 'male', 'right', 'Arlington', 'TX', 'US') RETURNING id INTO p15_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'tyler.n@demo.com', pw_hash, 'Tyler', 'Nguyen', '1990-06-14', 'player', 'unclaimed', 'male', 'right', 'Arlington', 'TX', 'US') RETURNING id INTO p15_id; INSERT INTO users (public_id, email, password_hash, first_name, last_name, date_of_birth, role, status, gender, handedness, city, state_province, country) -VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'olivia.s@demo.com', pw_hash, 'Olivia', 'Scott', '1998-01-25', 'player', 'active', 'female', 'left', 'Arlington', 'TX', 'US') RETURNING id INTO p16_id; +VALUES ('CC-' || lpad(nextval('user_public_id_seq')::TEXT, 5, '0'), 'olivia.s@demo.com', pw_hash, 'Olivia', 'Scott', '1998-01-25', 'player', 'unclaimed', 'female', 'left', 'Arlington', 'TX', 'US') RETURNING id INTO p16_id; -- ============================================================ -- 2. ORGANIZATIONS (3) @@ -590,7 +640,7 @@ INSERT INTO matches ( 'in_progress', 'quick', t3_id, t5_id, 11, 2, 1, 1, false, 3, 2, 1, 2, 1, - NOW() + INTERVAL '24 hours', p13_id + NOW() + INTERVAL '24 hours', admin_id ) RETURNING id INTO qm1_id; INSERT INTO matches ( @@ -603,7 +653,7 @@ INSERT INTO matches ( 'scheduled', 'quick', t7_id, t8_id, 15, 2, 1, 1, true, 0, 0, 1, 1, 1, - NOW() + INTERVAL '24 hours', p11_id + NOW() + INTERVAL '24 hours', td1_id ) RETURNING id INTO qm2_id; -- ============================================================ @@ -645,7 +695,7 @@ INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, expires_at) VALUES (admin_id, 'Admin Read-Only Key', encode(sha256('ccapi_demo_admin_key_001'::bytea), 'hex'), 'ccapi_demo_ad', ARRAY['read'], NOW() + INTERVAL '365 days'); INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, expires_at) -VALUES (broadcast_id, 'Broadcast API Key', encode(sha256('ccapi_demo_broadcast_key'::bytea), 'hex'), 'ccapi_demo_br', ARRAY['read'], NOW() + INTERVAL '90 days'); +VALUES (admin_id, 'Broadcast API Key', encode(sha256('ccapi_demo_broadcast_key'::bytea), 'hex'), 'ccapi_demo_br', ARRAY['read'], NOW() + INTERVAL '90 days'); -- ============================================================ -- 21. ACTIVITY LOGS (sample entries) @@ -681,16 +731,17 @@ INSERT INTO announcements (league_id, title, body, is_pinned, created_by_user_id RAISE NOTICE 'Seed data inserted successfully!'; RAISE NOTICE ''; -RAISE NOTICE 'Test accounts (password: TestPass123!):'; -RAISE NOTICE ' admin@courtcommand.com (platform_admin)'; -RAISE NOTICE ' td1@courtcommand.com (tournament_director)'; -RAISE NOTICE ' td2@courtcommand.com (tournament_director)'; -RAISE NOTICE ' ref1@courtcommand.com (head_referee)'; -RAISE NOTICE ' ref2@courtcommand.com (referee)'; -RAISE NOTICE ' scorekeeper@courtcommand.com (scorekeeper)'; -RAISE NOTICE ' broadcast@courtcommand.com (broadcast_operator)'; -RAISE NOTICE ' daniel.f.velez@gmail.com (platform_admin) — password: PASSword123!'; -RAISE NOTICE ' alex.j@demo.com through olivia.s@demo.com (16 players)'; +RAISE NOTICE 'Sign-in: bootstrap admin (Logto-mirrored, id=%) is the only user who can sign in', admin_id; +RAISE NOTICE ''; +RAISE NOTICE 'Shadow users (logto_user_id IS NULL, cannot sign in directly):'; +RAISE NOTICE ' td1@courtcommand.com / td2@courtcommand.com (tournament_director, status=active)'; +RAISE NOTICE ' ref1@courtcommand.com / ref2@courtcommand.com (head_referee / referee, status=active)'; +RAISE NOTICE ' scorekeeper@courtcommand.com (status=active)'; +RAISE NOTICE ' broadcast@courtcommand.com (broadcast_operator, status=active)'; +RAISE NOTICE ' daniel.f.velez@gmail.com (platform_admin, status=unclaimed)'; +RAISE NOTICE ' alex.j@demo.com through olivia.s@demo.com (16 players, status=unclaimed)'; +RAISE NOTICE 'When a real Logto user signs up with a matching email, Phase 4+ claim'; +RAISE NOTICE 'logic will merge the rows -- not implemented yet.'; RAISE NOTICE ''; -- ========================================== -- 17. AD CONFIGS (RelentNet default ads) @@ -703,8 +754,28 @@ VALUES RAISE NOTICE ' 3 ad configs (RelentNet)'; +-- ========================================== +-- 18. SPORT_ID BACKFILL (Phase 2+) +-- ========================================== +-- Phase 2 added sport_id to organizations/leagues/tournaments/venues/ +-- divisions, all nullable. The migration backfilled existing rows but +-- the seed inserts above don't supply sport_id, so they default to NULL. +-- Backend list queries filter by X-Sport, so NULL means invisible. Here +-- we attach every seeded row to Pickleball so it shows up under +-- /pickleball/* in the SPA. (Phase 6 cutover will flip these columns +-- to NOT NULL and the seed inserts will be amended to set sport_id +-- inline.) +UPDATE organizations SET sport_id = pickleball_id WHERE sport_id IS NULL; +UPDATE leagues SET sport_id = pickleball_id WHERE sport_id IS NULL; +UPDATE tournaments SET sport_id = pickleball_id WHERE sport_id IS NULL; +UPDATE venues SET sport_id = pickleball_id WHERE sport_id IS NULL; +UPDATE divisions SET sport_id = pickleball_id WHERE sport_id IS NULL; + +RAISE NOTICE ' Backfilled sport_id=pickleball on all seeded fixtures'; + RAISE NOTICE 'Entities created:'; -RAISE NOTICE ' 24 users, 3 orgs, 8 teams, 2 venues, 8 courts'; +RAISE NOTICE ' 1 bootstrap admin (preserved) + 23 shadow users (16 unclaimed players + 7 staff)'; +RAISE NOTICE ' 3 orgs, 8 teams, 2 venues, 8 courts'; RAISE NOTICE ' 2 leagues, 3 seasons, 2 division templates'; RAISE NOTICE ' 3 tournaments, 6 divisions, 2 pods'; RAISE NOTICE ' 12 registrations, 6 bracket matches + 2 quick matches'; diff --git a/api/go.mod b/api/go.mod index 8efd143..d68d587 100644 --- a/api/go.mod +++ b/api/go.mod @@ -4,8 +4,10 @@ go 1.26.2 require ( github.com/go-chi/chi/v5 v5.2.5 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.9.1 github.com/joho/godotenv v1.5.1 + github.com/lestrrat-go/jwx/v3 v3.1.0 github.com/pressly/goose/v3 v3.27.0 github.com/redis/go-redis/v9 v9.18.0 github.com/stretchr/testify v1.11.1 @@ -15,19 +17,29 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gorilla/websocket v1.5.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.2.1 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/valyala/fastjson v1.6.10 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/go.sum b/api/go.sum index c55bcd4..ae968ee 100644 --- a/api/go.sum +++ b/api/go.sum @@ -8,12 +8,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -34,6 +38,20 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc= +github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= +github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -50,13 +68,18 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= diff --git a/api/handler/admin.go b/api/handler/admin.go index 4f32118..3b31faf 100644 --- a/api/handler/admin.go +++ b/api/handler/admin.go @@ -10,7 +10,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" + "github.com/court-command/court-command/auth" "github.com/court-command/court-command/db/generated" + "github.com/court-command/court-command/logto" "github.com/court-command/court-command/service" "github.com/court-command/court-command/session" ) @@ -22,15 +24,23 @@ type AdminHandler struct { apiKeySvc *service.ApiKeyService sessionStore *session.Store uploadSvc *service.UploadService + // logtoClient mints subject tokens for the Logto-native impersonation + // flow (OAuth 2.0 Token Exchange). May be nil in cookie-only / testutil + // environments without Logto Management API creds; the impersonate + // endpoint returns 503 in that case. + logtoClient *logto.Client } -// NewAdminHandler creates a new AdminHandler. +// NewAdminHandler creates a new AdminHandler. logtoClient may be nil in +// environments without Logto Management API creds (testutil, dev-without-Logto); +// the JWT impersonation endpoint requires it and 503s when absent. func NewAdminHandler( queries *generated.Queries, activityLogSvc *service.ActivityLogService, apiKeySvc *service.ApiKeyService, sessionStore *session.Store, uploadSvc *service.UploadService, + logtoClient *logto.Client, ) *AdminHandler { return &AdminHandler{ queries: queries, @@ -38,6 +48,7 @@ func NewAdminHandler( apiKeySvc: apiKeySvc, sessionStore: sessionStore, uploadSvc: uploadSvc, + logtoClient: logtoClient, } } @@ -51,6 +62,9 @@ func (h *AdminHandler) Routes() chi.Router { r.Get("/users/{userID}", h.GetUser) r.Patch("/users/{userID}/role", h.UpdateUserRole) r.Patch("/users/{userID}/status", h.UpdateUserStatus) + // Logto-native impersonation: mint a subject token the SPA exchanges at + // Logto's /oidc/token. Mounted under the admin group (platform_admin only). + r.Post("/users/{userID}/impersonate", h.ImpersonateUser) // Venue management r.Get("/venues/pending", h.ListPendingVenues) @@ -67,8 +81,12 @@ func (h *AdminHandler) Routes() chi.Router { r.Post("/api-keys", h.CreateApiKey) r.Delete("/api-keys/{keyID}", h.RevokeApiKey) - // Impersonation (start only — stop is registered outside admin group in router.go) - r.Post("/impersonate/{userID}", h.StartImpersonation) + // Impersonation: Logto-native start endpoint is /users/{userID}/impersonate + // above. Stop is registered outside the admin group in router.go (the + // impersonated token is not platform_admin). The legacy cookie-path + // StartImpersonation handler/route is intentionally NOT mounted anymore — + // it was dead under JWT auth. See StartImpersonation's deprecation note; + // the handler is retained only until Phase 6 deletes the cookie store. // Upload cleanup r.Post("/uploads/cleanup", h.CleanOrphanedUploads) @@ -683,7 +701,115 @@ func (h *AdminHandler) CreateUnclaimedPlayer(w http.ResponseWriter, r *http.Requ }) } +// impersonateResponse is returned by ImpersonateUser. subject_token is the +// short-lived (~10 min), single-use Logto subject token the SPA exchanges at +// Logto's /oidc/token endpoint (grant_type=token-exchange) for an access token +// scoped to the target user. The token must never be logged. +type impersonateResponse struct { + SubjectToken string `json:"subject_token"` + Target struct { + PublicID string `json:"public_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Role string `json:"role"` + } `json:"target"` +} + +// ImpersonateUser handles POST /api/v1/admin/users/{userID}/impersonate. +// +// This is the JWT-path (Logto-native) impersonation entrypoint, replacing the +// dead cookie-based StartImpersonation below. The route is mounted inside the +// /admin group, which is gated by useAuth + RequirePlatformAdmin, so only +// platform_admins reach this handler. It: +// +// 1. Resolves the target user and rejects self-impersonation. +// 2. Calls the Logto Management API to mint a subject token for the target's +// Logto user ID (POST /api/subject-tokens), embedding the impersonator's +// identity in the subject-token context for end-to-end audit visibility. +// 3. Writes an activity_logs row (who impersonated whom). +// 4. Returns the subject token to the SPA, which exchanges it at Logto's +// /oidc/token for an access token whose sub=target and act.sub=admin. +// +// The exchanged access token is deliberately never produced or seen by this +// backend -- the SPA performs the exchange with the admin's own actor token. +func (h *AdminHandler) ImpersonateUser(w http.ResponseWriter, r *http.Request) { + sess := session.SessionData(r.Context()) + if sess == nil { + Unauthorized(w, "authentication required") + return + } + + if h.logtoClient == nil { + WriteError(w, http.StatusServiceUnavailable, "IMPERSONATION_UNAVAILABLE", + "impersonation requires Logto Management API configuration") + return + } + + target, ok := h.resolveUserParam(w, r) + if !ok { + return + } + + if target.ID == sess.UserID { + WriteError(w, http.StatusBadRequest, "SELF_IMPERSONATION", "cannot impersonate yourself") + return + } + + if target.LogtoUserID == nil || *target.LogtoUserID == "" { + // Unclaimed / not-yet-mirrored users have no Logto identity to assume. + WriteError(w, http.StatusBadRequest, "NO_LOGTO_IDENTITY", + "target user has no linked Logto account and cannot be impersonated") + return + } + + // Embed the impersonator + a machine-readable reason in the subject-token + // context so the audit signal is visible in Logto's own logs and in the + // issued impersonation token, not just our activity_logs. + // + // impersonator_logto_id MUST be the calling admin's Logto user ID as a + // STRING: the JWT customizer maps it to act.sub, and both auth.actorSubject + // (api/auth/context.go) and the SPA's impersonation banner expect a string + // there. We read it from the admin's own validated token (claims.Subject) + // rather than the local user row so the value matches Logto's user IDs. + subjectCtx := map[string]interface{}{ + "impersonator_public_id": sess.PublicID, + "impersonator_user_id": sess.UserID, + "reason": "court_command_admin_impersonation", + } + if claims, ok := auth.ClaimsFromContext(r.Context()); ok { + subjectCtx["impersonator_logto_id"] = claims.Subject + } + + tok, err := h.logtoClient.CreateSubjectToken(r.Context(), *target.LogtoUserID, subjectCtx) + if err != nil { + InternalError(w, "failed to create impersonation token") + return + } + + // Audit: record who impersonated whom. Mirrors the legacy + // start_impersonation action name so existing log filters keep working. + h.activityLogSvc.LogActivity(r.Context(), sess.UserID, "start_impersonation", "user", &target.ID, map[string]string{ + "target_user_public_id": target.PublicID, + "method": "logto_token_exchange", + }, r.RemoteAddr) + + resp := impersonateResponse{SubjectToken: tok.SubjectToken} + resp.Target.PublicID = target.PublicID + resp.Target.FirstName = target.FirstName + resp.Target.LastName = target.LastName + resp.Target.Role = target.Role + + Success(w, resp) +} + // StartImpersonation handles POST /admin/impersonate/{userID}. +// +// DEPRECATED legacy cookie-path impersonation. Non-functional under the JWT +// auth path the SPA now uses (it reads/writes the cc_session cookie which the +// JWT chain ignores). Superseded by ImpersonateUser above. Kept registered for +// the cookie-only testutil path until Phase 6 deletes the cookie session store +// entirely. Do NOT wire new callers to this. +// // Creates a new session as the target user with impersonator metadata. func (h *AdminHandler) StartImpersonation(w http.ResponseWriter, r *http.Request) { sess := session.SessionData(r.Context()) @@ -763,7 +889,17 @@ func (h *AdminHandler) StartImpersonation(w http.ResponseWriter, r *http.Request } // StopImpersonation handles POST /admin/stop-impersonation. -// Restores the admin's original session. +// +// Under the Logto-native (JWT) flow, "stopping" is performed client-side: the +// SPA discards the impersonation token and reverts to the admin's own token. +// This endpoint exists purely to write the matching audit-log entry. It +// detects the JWT path by reading the `act` claim off the request (the +// impersonation token carries act.sub=); when present it logs +// stop_impersonation and returns success WITHOUT touching the cookie store. +// +// The legacy cookie branch (sess.IsImpersonating(), via the cc_session +// Impersonator* fields) is retained for the cookie-only testutil path until +// Phase 6 removes the cookie session store. func (h *AdminHandler) StopImpersonation(w http.ResponseWriter, r *http.Request) { sess := session.SessionData(r.Context()) if sess == nil { @@ -771,6 +907,22 @@ func (h *AdminHandler) StopImpersonation(w http.ResponseWriter, r *http.Request) return } + // JWT path: the request is authenticated with an impersonation token whose + // act.sub identifies the impersonating admin. There is no server-side + // session to tear down (the SPA discards the token); just record the audit + // entry. sess.UserID is the impersonated (target) user's local id. + if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.IsImpersonated() { + var impersonatorID int64 + if admin, err := h.queries.GetUserByLogtoUserID(r.Context(), &claims.ActorSubject); err == nil { + impersonatorID = admin.ID + } + h.activityLogSvc.LogActivity(r.Context(), impersonatorID, "stop_impersonation", "user", &sess.UserID, map[string]string{ + "method": "logto_token_exchange", + }, r.RemoteAddr) + Success(w, map[string]interface{}{"restored": true}) + return + } + if !sess.IsImpersonating() { WriteError(w, http.StatusBadRequest, "NOT_IMPERSONATING", "not currently impersonating anyone") return diff --git a/api/handler/auth.go b/api/handler/auth.go index 635c061..4d189e2 100644 --- a/api/handler/auth.go +++ b/api/handler/auth.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "github.com/court-command/court-command/auth" "github.com/court-command/court-command/service" "github.com/court-command/court-command/session" ) @@ -141,6 +142,62 @@ func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { Success(w, resp) } +// MeJWT handles GET /api/v1/auth/me when the request is JWT-authenticated +// (Phase 3+). Reads claims from context (set by RequireJWT), looks up +// the local users mirror row by Logto user ID, returns the same +// MeResponse shape as the legacy Me handler. +// +// Impersonation under JWT is detected from the token's `act` claim (RFC +// 8693): when an admin impersonates via OAuth 2.0 Token Exchange, the +// exchanged access token has sub= and act.sub=. We surface +// that as MeResponse.Impersonation so the SPA renders the banner. The +// returned user IS the impersonated (target) user -- that's the whole point +// of impersonation; the impersonator's identity rides in the act claim. +func (h *AuthHandler) MeJWT(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + Unauthorized(w, "missing claims") + return + } + user, err := h.authService.GetCurrentUserByLogtoSubject(r.Context(), claims.Subject) + if err != nil { + var notFoundErr *service.NotFoundError + if errors.As(err, ¬FoundErr) { + NotFound(w, notFoundErr.Message) + return + } + InternalError(w, "failed to fetch user") + return + } + // Mirror the JWTSession bridge's role-elevation rule: the local DB + // row holds the default 'player' role from CreateUserFromLogto, but + // Logto's org-scoped token is the source of truth for platform_admin. + // Without this, the SPA sees user.role='player' even though + // handler-level checks (RequirePlatformAdmin) work fine -- and the + // SPA hides admin nav, scoring/broadcast tools, etc. Phase 4+ webhook + // will sync this back into the local DB on org-role changes; until + // then we apply the in-flight elevation on every /auth/me read. + if elevated := claims.ElevatedRole(); elevated != "" && elevated != user.Role { + user.Role = elevated + } + + resp := &MeResponse{UserResponse: user} + + // Impersonation signal: the act claim carries the impersonating admin's + // Logto user ID. We expose it as ImpersonatorID so the SPA banner can + // render. (We surface the raw Logto subject rather than a local public_id + // to avoid an extra DB lookup on every /me; the SPA only needs a boolean + // to render the banner today.) + if claims.IsImpersonated() { + resp.Impersonation = &ImpersonationInfo{ + Active: true, + ImpersonatorID: claims.ActorSubject, + } + } + + Success(w, resp) +} + // MyTournamentStaff handles GET /api/v1/auth/me/tournament-staff. func (h *AuthHandler) MyTournamentStaff(w http.ResponseWriter, r *http.Request) { sess := session.SessionData(r.Context()) diff --git a/api/handler/health.go b/api/handler/health.go index 92281b2..8adca5b 100644 --- a/api/handler/health.go +++ b/api/handler/health.go @@ -10,6 +10,28 @@ import ( "github.com/redis/go-redis/v9" ) +// buildCommit and buildBuiltAt are injected at link time via -ldflags so +// /api/v1/health can report which build is live. Defaults are used during +// `go run` and tests where the linker flags aren't applied. +// +// See api/Dockerfile for the production build invocation. The COMMIT +// build arg flows in from docker-compose.yaml's SOURCE_COMMIT (or +// COOLIFY_GIT_COMMIT_SHA) lookup. When Coolify does not expose the SHA +// at build time -- as is the case at the moment -- buildCommit stays +// "unknown" and operators rely on buildBuiltAt to confirm a fresh +// deploy. +// +// Phase 2 deploy: migration 00041 runs on startup via +// db.RunMigrations in main.go. A successful 200 response on this +// endpoint after a fresh built_at timestamp implicitly confirms the +// migration applied without error. +// +//nolint:gochecknoglobals // build-time constants +var ( + buildCommit = "dev" + buildBuiltAt = "unknown" +) + // HealthHandler checks the health of backend services. type HealthHandler struct { db *pgxpool.Pool @@ -51,5 +73,9 @@ func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) { "database": dbStatus, "redis": redisStatus, }, + "build": map[string]string{ + "commit": buildCommit, + "built_at": buildBuiltAt, + }, }) } diff --git a/api/handler/profile.go b/api/handler/profile.go new file mode 100644 index 0000000..491a27f --- /dev/null +++ b/api/handler/profile.go @@ -0,0 +1,101 @@ +// api/handler/profile.go +// +// ProfileHandler exposes the Phase 3 (Logto) /api/v1/me/profile endpoints. +// Both endpoints sit behind RequireJWT in the router; they read the +// validated Logto claims from the request context to identify the user. +// PATCH additionally requires the write:profile OAuth scope so a +// minimal-scope token (e.g. read-only client) cannot mutate profiles. +// +// The legacy cookie-session /api/v1/players/me endpoint owned by +// PlayerHandler stays untouched -- Phase 6 cutover deletes it once all +// frontend callers are migrated. Until then both endpoints coexist and +// read different storage (PlayerHandler => users table, this handler => +// player_profiles table). +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/service" +) + +// ProfileHandler binds the player_profiles service to the JWT-protected +// /me/profile routes. Construct via NewProfileHandler in main.go + +// testutil.TestServer; the zero value is unsafe. +type ProfileHandler struct { + profileService *service.ProfileService +} + +// NewProfileHandler returns a handler bound to the given ProfileService. +// The service owns both the row CRUD and the Logto-subject => users.id +// lookup; until Task 9's MirrorUser middleware lands the lookup happens +// per-request inside this handler. +func NewProfileHandler(p *service.ProfileService) *ProfileHandler { + return &ProfileHandler{profileService: p} +} + +// GetMyProfile returns the player_profiles row for the authenticated +// caller. When no row exists yet, ProfileService.Get returns an empty +// DTO with just user_id populated -- that's the contract the form's +// initial render relies on, so callers should treat all fields as +// optional and never expect a 404 here. +func (h *ProfileHandler) GetMyProfile(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + // RequireJWT should always populate claims before we reach here. + // Hitting this branch means the route was misconfigured (mounted + // without the middleware) -- surface as 500 to make that obvious. + WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "missing claims") + return + } + userID, err := h.profileService.LookupUserByLogtoSubject(r.Context(), claims.Subject) + if err != nil { + HandleServiceError(w, err) + return + } + profile, err := h.profileService.Get(r.Context(), userID) + if err != nil { + HandleServiceError(w, err) + return + } + Success(w, profile) +} + +// PatchMyProfile applies a partial update to player_profiles for the +// authenticated caller. The DTO uses the COALESCE narg pattern: nil +// fields leave the existing column unchanged, so the frontend can +// safely send only the fields the user actually touched. +// +// Authorisation: requires the write:profile scope on the token in +// addition to a valid signature. A token without the scope returns +// 403 FORBIDDEN -- not 401 -- because the caller IS authenticated, +// they just lack permission for this action. +func (h *ProfileHandler) PatchMyProfile(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "missing claims") + return + } + if !claims.HasScope("write:profile") { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "missing write:profile scope") + return + } + userID, err := h.profileService.LookupUserByLogtoSubject(r.Context(), claims.Subject) + if err != nil { + HandleServiceError(w, err) + return + } + var in service.PlayerProfileDTO + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + WriteError(w, http.StatusBadRequest, "BAD_JSON", err.Error()) + return + } + saved, err := h.profileService.Upsert(r.Context(), userID, in) + if err != nil { + HandleServiceError(w, err) + return + } + Success(w, saved) +} diff --git a/api/handler/profile_test.go b/api/handler/profile_test.go new file mode 100644 index 0000000..a972db9 --- /dev/null +++ b/api/handler/profile_test.go @@ -0,0 +1,269 @@ +// api/handler/profile_test.go +// +// These tests exercise ProfileHandler's GetMyProfile and PatchMyProfile +// directly against a real Postgres-backed ProfileService. They do NOT +// go through the chi router or the RequireJWT middleware -- instead each +// request is built with auth.WithClaims(...) on its context, which is +// exactly what RequireJWT sets after a successful token validation. +// This keeps the tests focused on handler+service+SQL behaviour and +// avoids having to spin up an httptest JWKS server just to mint tokens +// the validator will accept. Token validation itself is covered by +// api/middleware/jwt_middleware_test.go (Phase 1). +// +// Each test seeds its own user row directly and uses that user's +// logto_user_id as the JWT subject. ProfileService.LookupUserByLogtoSubject +// resolves it back to users.id the same way it would in production. +package handler_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/db/generated" + "github.com/court-command/court-command/handler" + "github.com/court-command/court-command/service" + "github.com/court-command/court-command/testutil" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// seedUserWithLogtoID inserts a users row with the given logto_user_id +// and returns the new local users.id. The row uses the writer-friendly +// minimal column set required by the table (NOT NULL columns + a +// nonempty password_hash sentinel that mirrors how the Logto webhook +// will populate webhook-created users in Task 9). +func seedUserWithLogtoID(t *testing.T, pool *pgxpool.Pool, email, logtoUserID string) int64 { + t.Helper() + var id int64 + // users.date_of_birth is NOT NULL on the legacy schema (Phase 6 will + // move it to player_profiles and drop the column); we set a fixed + // stub date so the seed satisfies the constraint without polluting + // the test's actual DOB assertions which target player_profiles. + err := pool.QueryRow(context.Background(), ` + INSERT INTO users (email, first_name, last_name, date_of_birth, password_hash, logto_user_id, status, role) + VALUES ($1, 'Test', 'User', '2000-01-01', '', $2, 'active', 'player') + RETURNING id`, email, logtoUserID).Scan(&id) + if err != nil { + t.Fatalf("seed user (%s): %v", email, err) + } + t.Cleanup(func() { + // Use hard-delete here for test isolation -- soft-delete (deleted_at) + // would break GetUserByLogtoUserID for any reused logto_user_id in + // later test runs. CASCADE drops the player_profiles row too. + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id) + }) + return id +} + +// newProfileHandler builds a ProfileHandler with a real, db-backed +// ProfileService against the given pool. It deliberately doesn't go +// through testutil.TestServer because we want to invoke handler methods +// directly with hand-crafted contexts. +func newProfileHandler(pool *pgxpool.Pool) *handler.ProfileHandler { + q := generated.New(pool) + return handler.NewProfileHandler(service.NewProfileService(q)) +} + +// reqWithClaims builds an httptest request with the given claims pre-set +// on its context, mimicking what RequireJWT does after token validation. +func reqWithClaims(method, path, body string, claims auth.Claims) *http.Request { + var r *http.Request + if body == "" { + r = httptest.NewRequest(method, path, nil) + } else { + r = httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + } + return r.WithContext(auth.WithClaims(r.Context(), claims)) +} + +// TestGetMyProfile_NoRowReturnsEmpty verifies the contract that a user +// with no player_profiles row yet still gets a 200 with an empty DTO +// (just user_id populated). The frontend form depends on this so it can +// render in "create" mode without having to handle a 404. +func TestGetMyProfile_NoRowReturnsEmpty(t *testing.T) { + pool := testutil.TestDB(t) + h := newProfileHandler(pool) + + logtoID := "logto_test_no_row" + userID := seedUserWithLogtoID(t, pool, "no-row@test.com", logtoID) + + req := reqWithClaims(http.MethodGet, "/api/v1/me/profile", "", + auth.Claims{Subject: logtoID, Scopes: []string{"read:profile"}}) + rec := httptest.NewRecorder() + h.GetMyProfile(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var got service.PlayerProfileDTO + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v body=%s", err, rec.Body.String()) + } + if got.UserID != userID { + t.Errorf("user_id: got %d want %d", got.UserID, userID) + } + if got.Phone != nil || got.Bio != nil || got.AddressLine1 != nil { + t.Errorf("expected empty profile fields for fresh user, got %+v", got) + } + if got.IsProfileHidden != false { + t.Errorf("is_profile_hidden default: got %v want false", got.IsProfileHidden) + } +} + +// TestGetMyProfile_ExistingRow inserts a profile row directly via SQL +// then confirms the handler returns the same fields. +func TestGetMyProfile_ExistingRow(t *testing.T) { + pool := testutil.TestDB(t) + h := newProfileHandler(pool) + + logtoID := "logto_test_existing" + userID := seedUserWithLogtoID(t, pool, "existing@test.com", logtoID) + + _, err := pool.Exec(context.Background(), ` + INSERT INTO player_profiles (user_id, phone, bio, address_line_1, is_profile_hidden) + VALUES ($1, '+15555550100', 'I like pickleball', '123 Main St', true)`, userID) + if err != nil { + t.Fatalf("seed profile: %v", err) + } + + req := reqWithClaims(http.MethodGet, "/api/v1/me/profile", "", + auth.Claims{Subject: logtoID, Scopes: []string{"read:profile"}}) + rec := httptest.NewRecorder() + h.GetMyProfile(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var got service.PlayerProfileDTO + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Phone == nil || *got.Phone != "+15555550100" { + t.Errorf("phone: got %v", got.Phone) + } + if got.Bio == nil || *got.Bio != "I like pickleball" { + t.Errorf("bio: got %v", got.Bio) + } + if got.AddressLine1 == nil || *got.AddressLine1 != "123 Main St" { + t.Errorf("address_line_1: got %v", got.AddressLine1) + } + if !got.IsProfileHidden { + t.Errorf("is_profile_hidden: got false want true") + } +} + +// TestPatchMyProfile_RequiresWriteScope verifies that a token without +// the write:profile scope is rejected with 403, even when the user is +// otherwise valid. This is the scope check that lets us issue +// minimal-scope tokens to read-only clients. +func TestPatchMyProfile_RequiresWriteScope(t *testing.T) { + pool := testutil.TestDB(t) + h := newProfileHandler(pool) + + logtoID := "logto_test_no_scope" + _ = seedUserWithLogtoID(t, pool, "no-scope@test.com", logtoID) + + body := `{"bio": "should not save"}` + req := reqWithClaims(http.MethodPatch, "/api/v1/me/profile", body, + auth.Claims{Subject: logtoID, Scopes: []string{"read:profile"}}) // no write + rec := httptest.NewRecorder() + h.PatchMyProfile(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "write:profile") { + t.Errorf("expected error message mentioning write:profile, got %s", rec.Body.String()) + } +} + +// TestPatchMyProfile_PartialUpdate_LeavesOtherFieldsAlone is the COALESCE +// contract test: we seed a row with phone set, PATCH only the bio, then +// GET and confirm phone is still there. Without the narg COALESCE +// pattern the upsert would null phone out. +func TestPatchMyProfile_PartialUpdate_LeavesOtherFieldsAlone(t *testing.T) { + pool := testutil.TestDB(t) + h := newProfileHandler(pool) + + logtoID := "logto_test_partial" + userID := seedUserWithLogtoID(t, pool, "partial@test.com", logtoID) + + _, err := pool.Exec(context.Background(), ` + INSERT INTO player_profiles (user_id, phone, paddle_brand) + VALUES ($1, '+15555550200', 'Selkirk')`, userID) + if err != nil { + t.Fatalf("seed profile: %v", err) + } + + // PATCH only bio. Note we explicitly do NOT send phone/paddle_brand. + body := `{"bio": "Updated bio"}` + req := reqWithClaims(http.MethodPatch, "/api/v1/me/profile", body, + auth.Claims{Subject: logtoID, Scopes: []string{"write:profile"}}) + rec := httptest.NewRecorder() + h.PatchMyProfile(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var got service.PlayerProfileDTO + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Bio == nil || *got.Bio != "Updated bio" { + t.Errorf("bio: got %v want 'Updated bio'", got.Bio) + } + if got.Phone == nil || *got.Phone != "+15555550200" { + t.Errorf("phone preserved? got %v want '+15555550200'", got.Phone) + } + if got.PaddleBrand == nil || *got.PaddleBrand != "Selkirk" { + t.Errorf("paddle_brand preserved? got %v want 'Selkirk'", got.PaddleBrand) + } + _ = userID +} + +// TestPatchMyProfile_DateOfBirth_Roundtrip exercises the YYYY-MM-DD +// pgtype.Date conversion path on both the inbound (DTO->params) and +// outbound (row->DTO) sides. A bug in either translation would surface +// here as a mismatched string or a 400. +func TestPatchMyProfile_DateOfBirth_Roundtrip(t *testing.T) { + pool := testutil.TestDB(t) + h := newProfileHandler(pool) + + logtoID := "logto_test_dob" + _ = seedUserWithLogtoID(t, pool, "dob@test.com", logtoID) + + body := `{"date_of_birth": "1990-01-15"}` + req := reqWithClaims(http.MethodPatch, "/api/v1/me/profile", body, + auth.Claims{Subject: logtoID, Scopes: []string{"write:profile"}}) + rec := httptest.NewRecorder() + h.PatchMyProfile(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("PATCH expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // GET it back and confirm round-trip. + getReq := reqWithClaims(http.MethodGet, "/api/v1/me/profile", "", + auth.Claims{Subject: logtoID, Scopes: []string{"read:profile"}}) + getRec := httptest.NewRecorder() + h.GetMyProfile(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("GET expected 200, got %d: %s", getRec.Code, getRec.Body.String()) + } + var got service.PlayerProfileDTO + if err := json.Unmarshal(getRec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.DateOfBirth == nil { + t.Fatalf("date_of_birth was nil after round-trip; body=%s", getRec.Body.String()) + } + if *got.DateOfBirth != "1990-01-15" { + t.Errorf("date_of_birth: got %q want %q", *got.DateOfBirth, "1990-01-15") + } +} diff --git a/api/handler/public.go b/api/handler/public.go index 04de48c..2ea38e5 100644 --- a/api/handler/public.go +++ b/api/handler/public.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/court-command/court-command/db/generated" "github.com/court-command/court-command/service" @@ -99,12 +100,13 @@ func toPublicVenues(vs []generated.Venue) []publicVenue { // PublicHandler handles unauthenticated public directory endpoints. type PublicHandler struct { - queries *generated.Queries - matchSvc *service.MatchService - divisionSvc *service.DivisionService - venueSvc *service.VenueService - seasonSvc *service.SeasonService - tournSvc *service.TournamentService + queries *generated.Queries + matchSvc *service.MatchService + divisionSvc *service.DivisionService + venueSvc *service.VenueService + seasonSvc *service.SeasonService + tournSvc *service.TournamentService + standingsSvc *service.StandingsService } // NewPublicHandler creates a new PublicHandler. @@ -137,6 +139,11 @@ func (h *PublicHandler) SetTournamentService(svc *service.TournamentService) { h.tournSvc = svc } +// SetStandingsService injects the StandingsService for division standings endpoints. +func (h *PublicHandler) SetStandingsService(svc *service.StandingsService) { + h.standingsSvc = svc +} + // Routes returns the Chi routes for public endpoints. func (h *PublicHandler) Routes() chi.Router { r := chi.NewRouter() @@ -148,6 +155,12 @@ func (h *PublicHandler) Routes() chi.Router { r.Get("/tournaments/{slug}/matches", h.ListTournamentMatches) r.Get("/tournaments/{slug}/courts", h.ListTournamentCourts) + // Division detail (spectator bracket/standings/matches views) + r.Get("/divisions/{divisionID}", h.GetDivision) + r.Get("/divisions/{divisionID}/bracket", h.GetDivisionBracket) + r.Get("/divisions/{divisionID}/standings", h.GetDivisionStandings) + r.Get("/divisions/{divisionID}/matches", h.ListDivisionMatches) + // League directory r.Get("/leagues", h.ListLeagues) r.Get("/leagues/{slug}", h.GetLeagueBySlug) @@ -443,6 +456,188 @@ func (h *PublicHandler) ListTournamentCourts(w http.ResponseWriter, r *http.Requ Success(w, courts) } +// --- Division detail sub-resources --- + +// resolveDivisionByID loads a division by its numeric ID and its parent +// tournament, enforcing the same public-visibility posture as the rest of the +// public API (draft tournaments are hidden). On any failure it writes a 404 +// with the standard error envelope and returns ok=false. +func (h *PublicHandler) resolveDivisionByID(w http.ResponseWriter, r *http.Request) (generated.Division, generated.Tournament, bool) { + divisionID, err := strconv.ParseInt(chi.URLParam(r, "divisionID"), 10, 64) + if err != nil { + WriteError(w, http.StatusNotFound, "NOT_FOUND", "Division not found") + return generated.Division{}, generated.Tournament{}, false + } + + division, err := h.queries.GetDivisionByID(r.Context(), divisionID) + if err != nil { + WriteError(w, http.StatusNotFound, "NOT_FOUND", "Division not found") + return generated.Division{}, generated.Tournament{}, false + } + + tournament, err := h.queries.GetTournamentByID(r.Context(), division.TournamentID) + if err != nil || tournament.Status == "draft" { + WriteError(w, http.StatusNotFound, "NOT_FOUND", "Division not found") + return generated.Division{}, generated.Tournament{}, false + } + + return division, tournament, true +} + +// publicDivisionDetail is the response shape for a single public division. +// It embeds the standard division fields plus parent tournament context and +// counts so a logged-out spectator landing on a division can render a header. +type publicDivisionDetail struct { + ID int64 `json:"id"` + TournamentID int64 `json:"tournament_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Format string `json:"format"` + BracketFormat string `json:"bracket_format"` + ScoringFormat *string `json:"scoring_format,omitempty"` + Status string `json:"status"` + CurrentPhase *string `json:"current_phase,omitempty"` + + Tournament publicDivisionTournament `json:"tournament"` + Counts publicDivisionCounts `json:"counts"` +} + +// publicDivisionTournament is the parent tournament context on a division detail. +type publicDivisionTournament struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` +} + +// publicDivisionCounts holds entity counts for a division. +type publicDivisionCounts struct { + Registrations int64 `json:"registrations"` + Matches int64 `json:"matches"` +} + +// GetDivision handles GET /api/v1/public/divisions/{divisionID} +// Returns division detail (name, format, bracket_format, status, phase) plus +// parent tournament slug+name and registration/match counts. +func (h *PublicHandler) GetDivision(w http.ResponseWriter, r *http.Request) { + division, tournament, ok := h.resolveDivisionByID(w, r) + if !ok { + return + } + + // Counts are best-effort: a count failure should not fail the detail view. + regCount, _ := h.queries.CountRegistrationsByDivision(r.Context(), division.ID) + matchCount, _ := h.queries.CountMatchesByDivision(r.Context(), pgtype.Int8{Int64: division.ID, Valid: true}) + + Success(w, publicDivisionDetail{ + ID: division.ID, + TournamentID: division.TournamentID, + Name: division.Name, + Slug: division.Slug, + Format: division.Format, + BracketFormat: division.BracketFormat, + ScoringFormat: division.ScoringFormat, + Status: division.Status, + CurrentPhase: division.CurrentPhase, + Tournament: publicDivisionTournament{ + ID: tournament.ID, + Slug: tournament.Slug, + Name: tournament.Name, + }, + Counts: publicDivisionCounts{ + Registrations: regCount, + Matches: matchCount, + }, + }) +} + +// GetDivisionBracket handles GET /api/v1/public/divisions/{divisionID}/bracket +// Returns the division's matches with their bracket wiring (next_match_id, +// next_match_slot, round, seeds, etc.), which is the exact shape the +// authenticated frontend bracket renderer consumes via +// GET /api/v1/divisions/{divisionID}/matches. Returned as a paginated envelope +// so the bracket renderer can reuse its existing match-list parsing. +func (h *PublicHandler) GetDivisionBracket(w http.ResponseWriter, r *http.Request) { + if h.matchSvc == nil { + WriteError(w, http.StatusInternalServerError, "NOT_CONFIGURED", "Bracket not available") + return + } + + division, _, ok := h.resolveDivisionByID(w, r) + if !ok { + return + } + + // Brackets can be large; fetch a generous page so the whole tree comes back. + limit, offset := parseLimitOffset(r, 200, 500) + + matches, total, err := h.matchSvc.ListByDivision(r.Context(), division.ID, limit, offset) + if err != nil { + WriteError(w, http.StatusInternalServerError, "LIST_FAILED", "Failed to load bracket") + return + } + + Paginated(w, matches, total, int(limit), int(offset)) +} + +// GetDivisionStandings handles GET /api/v1/public/divisions/{divisionID}/standings +// Returns the division's standings entries in the same shape as the +// authenticated GET /api/v1/standings/seasons/{seasonID}/divisions/{divisionID}. +// Standings are season-scoped; the parent tournament's season is resolved +// automatically. A tournament with no season (or no entries) yields an empty +// paginated list rather than an error. +func (h *PublicHandler) GetDivisionStandings(w http.ResponseWriter, r *http.Request) { + if h.standingsSvc == nil { + WriteError(w, http.StatusInternalServerError, "NOT_CONFIGURED", "Standings not available") + return + } + + division, tournament, ok := h.resolveDivisionByID(w, r) + if !ok { + return + } + + limit, offset := parseLimitOffset(r, 100, 500) + + // Standings only exist for divisions whose tournament belongs to a season. + if !tournament.SeasonID.Valid { + Paginated(w, []service.StandingsEntryResponse{}, 0, int(limit), int(offset)) + return + } + + entries, total, err := h.standingsSvc.ListByDivision(r.Context(), tournament.SeasonID.Int64, division.ID, limit, offset) + if err != nil { + WriteError(w, http.StatusInternalServerError, "LIST_FAILED", "Failed to load standings") + return + } + + Paginated(w, entries, total, int(limit), int(offset)) +} + +// ListDivisionMatches handles GET /api/v1/public/divisions/{divisionID}/matches +// Returns the matches in a division (same listing as the authenticated +// GET /api/v1/divisions/{divisionID}/matches). +func (h *PublicHandler) ListDivisionMatches(w http.ResponseWriter, r *http.Request) { + if h.matchSvc == nil { + WriteError(w, http.StatusInternalServerError, "NOT_CONFIGURED", "Matches not available") + return + } + + division, _, ok := h.resolveDivisionByID(w, r) + if !ok { + return + } + + limit, offset := parseLimitOffset(r, 50, 200) + + matches, total, err := h.matchSvc.ListByDivision(r.Context(), division.ID, limit, offset) + if err != nil { + WriteError(w, http.StatusInternalServerError, "LIST_FAILED", "Failed to list matches") + return + } + + Paginated(w, matches, total, int(limit), int(offset)) +} + // --- League sub-resources --- // resolveLeagueBySlug looks up a league by slug and validates it's publicly visible. diff --git a/api/handler/sports.go b/api/handler/sports.go new file mode 100644 index 0000000..d2e218a --- /dev/null +++ b/api/handler/sports.go @@ -0,0 +1,32 @@ +// api/handler/sports.go +package handler + +import ( + "net/http" + + "github.com/court-command/court-command/service" +) + +// SportsHandler exposes the public sport directory endpoint. The sport +// picker (unauthenticated landing page) calls this before the user has +// selected an organization, so it must be mounted in the public block of +// the router (no RequireAuth/RequireJWT middleware). +type SportsHandler struct { + sportsService *service.SportsService +} + +// NewSportsHandler creates a new SportsHandler. +func NewSportsHandler(s *service.SportsService) *SportsHandler { + return &SportsHandler{sportsService: s} +} + +// ListSports returns the active sports ordered by sort_order. Public +// endpoint — no auth required. Response shape: a JSON array of SportDTO. +func (h *SportsHandler) ListSports(w http.ResponseWriter, r *http.Request) { + sports, err := h.sportsService.List(r.Context()) + if err != nil { + HandleServiceError(w, err) + return + } + Success(w, sports) +} diff --git a/api/handler/sports_test.go b/api/handler/sports_test.go new file mode 100644 index 0000000..fcdcbee --- /dev/null +++ b/api/handler/sports_test.go @@ -0,0 +1,115 @@ +// api/handler/sports_test.go +package handler_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/court-command/court-command/testutil" +) + +// sportRow mirrors service.SportDTO for decoding without importing the +// service package (avoids tight coupling in the handler test). +type sportRow struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + LogtoOrgID string `json:"logto_org_id"` +} + +// TestListSports_ReturnsSeededSports verifies that the public +// /api/v1/sports endpoint returns the active sports seeded by migration +// 00041 (pickleball + demo). It also confirms no auth is required. +func TestListSports_ReturnsSeededSports(t *testing.T) { + pool := testutil.TestDB(t) + ts := testutil.TestServer(t, pool) + defer ts.Close() + + // No auth header / cookie — endpoint is public. + resp, err := http.Get(ts.URL + "/api/v1/sports") + if err != nil { + t.Fatalf("GET /api/v1/sports: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var got []sportRow + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + + // Migration seeds at least pickleball; demo may or may not exist + // depending on environment. Be flexible but assert at least one row + // and that pickleball is present with a non-empty logto_org_id. + if len(got) == 0 { + t.Fatalf("expected at least one sport, got 0") + } + + var foundPickleball bool + for _, s := range got { + if s.Slug == "" { + t.Errorf("sport with id=%d has empty slug", s.ID) + } + if s.Name == "" { + t.Errorf("sport %q has empty name", s.Slug) + } + if s.LogtoOrgID == "" { + t.Errorf("sport %q has empty logto_org_id (frontend needs this for org-scoped tokens)", s.Slug) + } + if s.Slug == "pickleball" { + foundPickleball = true + } + } + if !foundPickleball { + t.Errorf("expected pickleball in seeded sports, got %+v", got) + } +} + +// TestListSports_OmitsInactiveSports verifies that sports with +// is_active=false are filtered out by the underlying ListSports query. +func TestListSports_OmitsInactiveSports(t *testing.T) { + pool := testutil.TestDB(t) + + // Insert an inactive test sport. Use ON CONFLICT to remain idempotent + // across reruns of this test against the same DB. + _, err := pool.Exec(context.Background(), + `INSERT INTO sports (slug, name, logto_org_id, is_active, sort_order) + VALUES ('handler-test-inactive', 'Handler Test Inactive', 'org_handler_inactive', false, 999) + ON CONFLICT (slug) DO UPDATE SET is_active = false`) + if err != nil { + t.Fatalf("seed inactive sport: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), + `DELETE FROM sports WHERE slug = 'handler-test-inactive'`) + }) + + ts := testutil.TestServer(t, pool) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/v1/sports") + if err != nil { + t.Fatalf("GET /api/v1/sports: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var got []sportRow + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + + for _, s := range got { + if s.Slug == "handler-test-inactive" { + t.Errorf("inactive sport leaked into response: %+v", s) + } + } +} diff --git a/api/handler/webhooks_logto.go b/api/handler/webhooks_logto.go new file mode 100644 index 0000000..cbf5e73 --- /dev/null +++ b/api/handler/webhooks_logto.go @@ -0,0 +1,152 @@ +// api/handler/webhooks_logto.go +// +// LogtoWebhookHandler verifies HMAC-SHA-256 signatures on the +// POST /api/v1/webhooks/logto endpoint and dispatches three event +// types to the central UserSyncService: +// +// - User.Created => UserSyncService.UpsertFromLogto (insert) +// - User.Data.Updated => UserSyncService.UpsertFromLogto (update) +// - User.Deleted => UserSyncService.SoftDelete +// +// Unknown event types return 204 so Logto stops retrying them. Every +// other failure (bad signature, malformed JSON, DB error) returns a +// structured error envelope so it's actionable from the Logto admin +// "Recent deliveries" UI. +// +// This handler is mounted publicly (no auth middleware) -- the HMAC +// signature IS the auth. The signing key comes from the +// LOGTO_WEBHOOK_SIGNING_KEY environment variable (set by the seeder +// run that registers the webhook in Logto). +package handler + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "strings" + + "github.com/court-command/court-command/service" +) + +// LogtoWebhookHandler binds the user-sync service + signing key to the +// Handle method registered by the router. +type LogtoWebhookHandler struct { + userSync *service.UserSyncService + signingKey string +} + +// NewLogtoWebhookHandler returns a handler ready to mount. +// +// signingKey may be empty (e.g. webhook intentionally disabled in a +// test environment); in that case Handle short-circuits with a 500 +// "INTERNAL_ERROR" so a misconfigured production env can't silently +// accept unsigned requests. +func NewLogtoWebhookHandler(s *service.UserSyncService, signingKey string) *LogtoWebhookHandler { + return &LogtoWebhookHandler{userSync: s, signingKey: signingKey} +} + +// logtoWebhookEnvelope is the subset of the Logto webhook payload +// shape we consume. Logto sends additional metadata fields (createdAt, +// hookId, etc.) which we ignore. +type logtoWebhookEnvelope struct { + Event string `json:"event"` + UserID string `json:"userId"` + User *logtoUserPayload `json:"user,omitempty"` +} + +type logtoUserPayload struct { + ID string `json:"id"` + Username string `json:"username"` + PrimaryEmail string `json:"primaryEmail"` + Name string `json:"name"` +} + +// Handle is the http.HandlerFunc for POST /api/v1/webhooks/logto. +// +// Order of checks: +// 1. Signing key configured? (500 if not) +// 2. Body readable? (400 READ_BODY) +// 3. HMAC signature matches? (401 BAD_SIGNATURE -- constant-time compare) +// 4. JSON parseable? (400 BAD_JSON) +// 5. Event dispatch -- known events execute their service call; +// unknown events return 204 so Logto doesn't retry them forever. +func (h *LogtoWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) { + if h.signingKey == "" { + WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "webhook signing key not configured") + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + WriteError(w, http.StatusBadRequest, "READ_BODY", err.Error()) + return + } + // Logto sends "logto-signature-sha-256" (lowercase, no X- prefix). + // http.Header.Get is case-insensitive, so the canonical title-case + // form here matches the wire-level lowercase header. + got := r.Header.Get("Logto-Signature-Sha-256") + mac := hmac.New(sha256.New, []byte(h.signingKey)) + mac.Write(body) + want := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(got), []byte(want)) { + slog.WarnContext(r.Context(), "webhook signature mismatch", "got_len", len(got)) + WriteError(w, http.StatusUnauthorized, "BAD_SIGNATURE", "signature mismatch") + return + } + + var env logtoWebhookEnvelope + if err := json.Unmarshal(body, &env); err != nil { + WriteError(w, http.StatusBadRequest, "BAD_JSON", err.Error()) + return + } + + switch env.Event { + case "User.Created", "User.Data.Updated": + if env.User == nil { + WriteError(w, http.StatusBadRequest, "MISSING_USER", "no user payload") + return + } + first, last := splitName(env.User.Name) + if err := h.userSync.UpsertFromLogto(r.Context(), service.LogtoUserUpsert{ + LogtoUserID: env.User.ID, + Email: env.User.PrimaryEmail, + FirstName: first, + LastName: last, + DisplayName: env.User.Name, + }); err != nil { + slog.ErrorContext(r.Context(), "user upsert failed", "event", env.Event, "err", err) + HandleServiceError(w, err) + return + } + case "User.Deleted": + if err := h.userSync.SoftDelete(r.Context(), env.UserID); err != nil { + slog.ErrorContext(r.Context(), "user delete failed", "err", err) + HandleServiceError(w, err) + return + } + default: + // Unknown events: 204 so Logto stops retrying. We log them so + // operators can spot a new event type that should be handled. + slog.InfoContext(r.Context(), "unhandled webhook event", "event", env.Event) + } + NoContent(w) +} + +// splitName splits a Logto display name into first/last on the first +// space. "Alice Bob Carol" => ("Alice", "Bob Carol"). An empty or +// whitespace-only name returns two empty strings; the caller should +// decide whether that's worth surfacing. +func splitName(full string) (first, last string) { + parts := strings.SplitN(strings.TrimSpace(full), " ", 2) + if len(parts) == 0 || parts[0] == "" { + return "", "" + } + first = parts[0] + if len(parts) > 1 { + last = parts[1] + } + return +} diff --git a/api/handler/webhooks_logto_test.go b/api/handler/webhooks_logto_test.go new file mode 100644 index 0000000..99129f3 --- /dev/null +++ b/api/handler/webhooks_logto_test.go @@ -0,0 +1,314 @@ +// api/handler/webhooks_logto_test.go +// +// End-to-end tests for the Logto webhook endpoint. Each test posts a +// real HTTP request to a testutil.TestServer (which mounts the actual +// router and a Postgres-backed UserSyncService) and verifies both the +// HTTP response and the resulting users-table state. +// +// The tests build their own LogtoWebhookHandler + UserSyncService +// against the test pool and mount them on a separate chi router so +// the signing key can be set per-test without polluting environment +// variables. testutil.TestServer doesn't (yet) wire the webhook +// handler -- but it doesn't need to; everything Task 9 cares about is +// covered by exercising the handler directly. +package handler_test + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/court-command/court-command/db/generated" + "github.com/court-command/court-command/handler" + "github.com/court-command/court-command/service" + "github.com/court-command/court-command/testutil" +) + +// signWebhook computes the hex-encoded HMAC-SHA-256 of body under key, +// matching what Logto sends in the Logto-Signature-Sha-256 header. +func signWebhook(key, body []byte) string { + mac := hmac.New(sha256.New, key) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +// webhookFixture builds an httptest server that mounts ONLY the Logto +// webhook handler at /api/v1/webhooks/logto. Returns the server URL +// and a teardown is registered via t.Cleanup. The signing key is +// stored on the closure-bound handler so callers can sign matching +// payloads. +type webhookFixture struct { + url string + signingKey []byte + pool *pgxpool.Pool +} + +func newWebhookFixture(t *testing.T, signingKey string) *webhookFixture { + t.Helper() + pool := testutil.TestDB(t) + q := generated.New(pool) + uss := service.NewUserSyncService(q) + wh := handler.NewLogtoWebhookHandler(uss, signingKey) + + r := chi.NewRouter() + r.Post("/api/v1/webhooks/logto", wh.Handle) + + ts := httptest.NewServer(r) + t.Cleanup(ts.Close) + + return &webhookFixture{ + url: ts.URL, + signingKey: []byte(signingKey), + pool: pool, + } +} + +// postWebhook posts the given body to the fixture, optionally with a +// pre-computed signature header. If sig is empty, no header is sent. +func (f *webhookFixture) postWebhook(t *testing.T, body []byte, sig string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, f.url+"/api/v1/webhooks/logto", bytes.NewReader(body)) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if sig != "" { + // Title-case header matches the lowercase wire form + // (HTTP headers are case-insensitive on retrieval). + req.Header.Set("Logto-Signature-Sha-256", sig) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + return resp +} + +// cleanupUser hard-deletes a users row by logto_user_id at test +// teardown so re-runs don't collide on the unique partial index. +func cleanupUser(t *testing.T, pool *pgxpool.Pool, logtoID string) { + t.Helper() + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE logto_user_id = $1`, logtoID) + }) +} + +// TestWebhook_RejectsBadSignature verifies the signature gate. We +// deliberately use the WRONG header name to also guard against the +// "X-Logto-Signature-SHA-256" regression (amendment A7): even with a +// correct HMAC value, the wrong header name means the handler reads +// "" and rejects. +func TestWebhook_RejectsBadSignature(t *testing.T) { + f := newWebhookFixture(t, "test-key") + body := []byte(`{"event":"User.Created","userId":"u1","user":{"id":"u1","primaryEmail":"x@y.z"}}`) + + // Wrong signature value. + resp := f.postWebhook(t, body, "deadbeef") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Correct signature, wrong header name (X- prefix). Build the + // request manually to set the wrong header. + req, _ := http.NewRequest(http.MethodPost, f.url+"/api/v1/webhooks/logto", bytes.NewReader(body)) + req.Header.Set("X-Logto-Signature-SHA-256", signWebhook(f.signingKey, body)) + resp2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("wrong-header-name request: expected 401, got %d", resp2.StatusCode) + } +} + +// TestWebhook_AcceptsValidSignature_Created_InsertsUser verifies the +// happy path for User.Created: 204 No Content + a row in the users +// table with the expected fields populated. +func TestWebhook_AcceptsValidSignature_Created_InsertsUser(t *testing.T) { + f := newWebhookFixture(t, "test-key") + + logtoID := "logto_test_webhook_create" + cleanupUser(t, f.pool, logtoID) + + payload := map[string]interface{}{ + "event": "User.Created", + "userId": logtoID, + "user": map[string]interface{}{ + "id": logtoID, + "primaryEmail": "alice.created@example.com", + "name": "Alice Wonder", + }, + } + body, _ := json.Marshal(payload) + sig := signWebhook(f.signingKey, body) + + resp := f.postWebhook(t, body, sig) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", resp.StatusCode) + } + + // Verify the row landed. + var ( + gotEmail *string + gotFirstName string + gotLastName string + gotStatus string + ) + err := f.pool.QueryRow(context.Background(), + `SELECT email, first_name, last_name, status FROM users WHERE logto_user_id = $1`, + logtoID).Scan(&gotEmail, &gotFirstName, &gotLastName, &gotStatus) + if err != nil { + t.Fatalf("query inserted user: %v", err) + } + if gotEmail == nil || *gotEmail != "alice.created@example.com" { + t.Errorf("email: got %v want alice.created@example.com", gotEmail) + } + if gotFirstName != "Alice" { + t.Errorf("first_name: got %q want Alice", gotFirstName) + } + if gotLastName != "Wonder" { + t.Errorf("last_name: got %q want Wonder", gotLastName) + } + if gotStatus != "active" { + t.Errorf("status: got %q want active", gotStatus) + } +} + +// TestWebhook_AcceptsValidSignature_Updated_UpdatesEmail seeds a row +// then sends User.Data.Updated with a new email; the row's email must +// be updated. first_name/last_name are intentionally NOT touched by +// updates per the SQL contract. +func TestWebhook_AcceptsValidSignature_Updated_UpdatesEmail(t *testing.T) { + f := newWebhookFixture(t, "test-key") + + logtoID := "logto_test_webhook_update" + cleanupUser(t, f.pool, logtoID) + + // Seed a row with the OLD email. + _, err := f.pool.Exec(context.Background(), ` + INSERT INTO users (email, first_name, last_name, date_of_birth, password_hash, logto_user_id, status, role) + VALUES ('old@example.com', 'Bob', 'Builder', '2000-01-01', '', $1, 'active', 'player') + `, logtoID) + if err != nil { + t.Fatalf("seed user: %v", err) + } + + payload := map[string]interface{}{ + "event": "User.Data.Updated", + "userId": logtoID, + "user": map[string]interface{}{ + "id": logtoID, + "primaryEmail": "new@example.com", + "name": "Bob Builder", + }, + } + body, _ := json.Marshal(payload) + sig := signWebhook(f.signingKey, body) + + resp := f.postWebhook(t, body, sig) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", resp.StatusCode) + } + + var gotEmail *string + err = f.pool.QueryRow(context.Background(), + `SELECT email FROM users WHERE logto_user_id = $1`, logtoID).Scan(&gotEmail) + if err != nil { + t.Fatalf("query updated user: %v", err) + } + if gotEmail == nil || *gotEmail != "new@example.com" { + t.Errorf("email after update: got %v want new@example.com", gotEmail) + } +} + +// TestWebhook_UserDeleted_SoftDeletes seeds a row, sends User.Deleted, +// and verifies deleted_at is set (status is intentionally NOT changed +// because the legacy CHECK constraint forbids 'deleted' as a status +// value -- soft-delete is conveyed by deleted_at IS NOT NULL). +func TestWebhook_UserDeleted_SoftDeletes(t *testing.T) { + f := newWebhookFixture(t, "test-key") + + logtoID := "logto_test_webhook_delete" + cleanupUser(t, f.pool, logtoID) + + _, err := f.pool.Exec(context.Background(), ` + INSERT INTO users (email, first_name, last_name, date_of_birth, password_hash, logto_user_id, status, role) + VALUES ('todelete@example.com', 'Del', 'Eted', '2000-01-01', '', $1, 'active', 'player') + `, logtoID) + if err != nil { + t.Fatalf("seed user: %v", err) + } + + payload := map[string]interface{}{ + "event": "User.Deleted", + "userId": logtoID, + } + body, _ := json.Marshal(payload) + sig := signWebhook(f.signingKey, body) + + resp := f.postWebhook(t, body, sig) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", resp.StatusCode) + } + + // deleted_at should be non-NULL now. Use a raw query (not the + // generated GetUserByLogtoUserID, which filters on deleted_at IS + // NULL). + var hasDeletedAt bool + err = f.pool.QueryRow(context.Background(), + `SELECT deleted_at IS NOT NULL FROM users WHERE logto_user_id = $1`, + logtoID).Scan(&hasDeletedAt) + if err != nil { + t.Fatalf("query deleted user: %v", err) + } + if !hasDeletedAt { + t.Errorf("expected deleted_at to be set after User.Deleted webhook") + } +} + +// TestWebhook_UnknownEvent_Returns204 verifies that an event type the +// handler doesn't recognize is acknowledged with 204 so Logto stops +// retrying. (Returning 4xx/5xx would cause Logto to back-off-and-retry +// forever for events we don't care about.) +func TestWebhook_UnknownEvent_Returns204(t *testing.T) { + f := newWebhookFixture(t, "test-key") + + body := []byte(`{"event":"Anything.Else","userId":"x"}`) + sig := signWebhook(f.signingKey, body) + + resp := f.postWebhook(t, body, sig) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 for unknown event, got %d", resp.StatusCode) + } +} + +// TestWebhook_MissingSigningKey_Returns500 verifies the +// fail-closed-on-misconfiguration behavior: a handler constructed with +// an empty signing key returns 500 INTERNAL_ERROR rather than silently +// accepting unsigned requests. Catches a deployment where the env var +// was never set. +func TestWebhook_MissingSigningKey_Returns500(t *testing.T) { + f := newWebhookFixture(t, "") + + body := []byte(`{"event":"User.Created","userId":"x","user":{"id":"x"}}`) + resp := f.postWebhook(t, body, "anything") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", resp.StatusCode) + } +} diff --git a/api/logto/applications.go b/api/logto/applications.go new file mode 100644 index 0000000..4ad3e22 --- /dev/null +++ b/api/logto/applications.go @@ -0,0 +1,216 @@ +package logto + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// Application is the Logto Management API representation of a registered +// application (SPA, M2M, native, traditional). Field set is intentionally +// narrow: just the fields the seed script and Phase 4 M2M flow read or write. +type Application struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + Secret string `json:"secret,omitempty"` + OIDCClientMetadata map[string]interface{} `json:"oidcClientMetadata,omitempty"` + CustomClientMetadata map[string]interface{} `json:"customClientMetadata,omitempty"` + IsAdmin bool `json:"isAdmin,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` +} + +// ApplicationType values accepted by the Logto Management API. +const ( + AppTypeSPA = "SPA" + AppTypeMachineToMachine = "MachineToMachine" + AppTypeNative = "Native" + AppTypeTraditionalWeb = "Traditional" +) + +// CreateApplicationParams is the body for POST /api/applications. +type CreateApplicationParams struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + OIDCClientMetadata map[string]interface{} `json:"oidcClientMetadata,omitempty"` + CustomClientMetadata map[string]interface{} `json:"customClientMetadata,omitempty"` +} + +// CreateApplication creates a new application and returns the created row +// (including the generated client secret for M2M apps). NOT idempotent -- +// callers needing idempotency should use FindApplicationByName first. +func (c *Client) CreateApplication(ctx context.Context, p CreateApplicationParams) (*Application, error) { + var a Application + if err := c.doJSON(ctx, http.MethodPost, "/api/applications", p, &a); err != nil { + return nil, err + } + return &a, nil +} + +// ListApplications returns up to pageSize applications. Logto's pagination +// uses page (1-based) + page_size; the seeder pages through with size=100 +// to find existing apps by name. +func (c *Client) ListApplications(ctx context.Context, page, pageSize int) ([]Application, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("page_size", strconv.Itoa(pageSize)) + + var apps []Application + path := "/api/applications?" + q.Encode() + if err := c.doJSON(ctx, http.MethodGet, path, nil, &apps); err != nil { + return nil, err + } + return apps, nil +} + +// FindApplicationByName scans the application list for an exact name match +// and returns the first hit, or nil if absent. Idempotent companion to +// CreateApplication for the seed flow. +func (c *Client) FindApplicationByName(ctx context.Context, name string) (*Application, error) { + const pageSize = 100 + for page := 1; page <= 100; page++ { // hard cap: 10k apps + apps, err := c.ListApplications(ctx, page, pageSize) + if err != nil { + return nil, err + } + for i := range apps { + if apps[i].Name == name { + return &apps[i], nil + } + } + if len(apps) < pageSize { + return nil, nil + } + } + return nil, fmt.Errorf("more than 10k applications; FindApplicationByName paging budget exhausted") +} + +// PatchApplicationParams is the body for PATCH /api/applications/:id. All +// fields are optional; only non-nil fields are sent so a partial update never +// clobbers metadata the caller didn't intend to touch. CustomClientMetadata +// is a full replacement of that object on Logto's side, so callers that want +// to preserve existing keys must merge first (see seedSPAApp). +type PatchApplicationParams struct { + CustomClientMetadata map[string]interface{} `json:"customClientMetadata,omitempty"` +} + +// PatchApplication updates an existing application and returns the updated +// row. Used by the seeder to flip customClientMetadata.allowTokenExchange on +// the SPA app so the OAuth 2.0 Token Exchange impersonation flow is permitted +// for that client. +func (c *Client) PatchApplication(ctx context.Context, appID string, p PatchApplicationParams) (*Application, error) { + var a Application + if err := c.doJSON(ctx, http.MethodPatch, "/api/applications/"+appID, p, &a); err != nil { + return nil, err + } + return &a, nil +} + +// AssignApplicationRoles attaches Logto-platform roles (e.g. the built-in +// "Logto Management API access" role) to a Machine-to-Machine application. +// roleIDs come from ListRoles. Idempotent: Logto returns 422 on duplicate +// assignment which the caller can surface or ignore. +func (c *Client) AssignApplicationRoles(ctx context.Context, appID string, roleIDs []string) error { + body := map[string]interface{}{"roleIds": roleIDs} + path := fmt.Sprintf("/api/applications/%s/roles", appID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// Role is a Logto-platform role (NOT the same as organization roles, which +// live on the organization template). Roles control what Logto Management +// API surface a holder can call. +type Role struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` +} + +// ListRoles returns all Logto-platform roles. Used by the seeder to find +// the built-in "Logto Management API access" role by name. +func (c *Client) ListRoles(ctx context.Context) ([]Role, error) { + var roles []Role + if err := c.doJSON(ctx, http.MethodGet, "/api/roles?page_size=100", nil, &roles); err != nil { + return nil, err + } + return roles, nil +} + +// FindRoleByName scans roles for an exact name match. +func (c *Client) FindRoleByName(ctx context.Context, name string) (*Role, error) { + roles, err := c.ListRoles(ctx) + if err != nil { + return nil, err + } + for i := range roles { + if roles[i].Name == name { + return &roles[i], nil + } + } + return nil, nil +} + +// CreateRoleParams is the body for POST /api/roles. +type CreateRoleParams struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + // Type is "User" or "MachineToMachine". The seeder creates User-type + // roles for the bootstrap admin's API resource scopes. + Type string `json:"type,omitempty"` +} + +// CreateRole creates a new Logto-platform role. +func (c *Client) CreateRole(ctx context.Context, p CreateRoleParams) (*Role, error) { + var r Role + if err := c.doJSON(ctx, http.MethodPost, "/api/roles", p, &r); err != nil { + return nil, err + } + return &r, nil +} + +// AssignScopesToRole binds a set of API resource scopes to a role. +// scopeIDs come from ListResourceScopes(resourceID). Idempotent: Logto +// rejects already-bound scopes; the seeder catches the 422 and ignores it. +func (c *Client) AssignScopesToRole(ctx context.Context, roleID string, scopeIDs []string) error { + body := map[string]interface{}{"scopeIds": scopeIDs} + path := fmt.Sprintf("/api/roles/%s/scopes", roleID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// ListRoleScopes returns the API resource scopes bound to a role. +func (c *Client) ListRoleScopes(ctx context.Context, roleID string) ([]Scope, error) { + var scopes []Scope + path := fmt.Sprintf("/api/roles/%s/scopes?page_size=100", roleID) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &scopes); err != nil { + return nil, err + } + return scopes, nil +} + +// AssignRolesToUser grants Logto-platform roles to a user. Idempotent on +// Logto's side; 422 on duplicate is the expected response. +func (c *Client) AssignRolesToUser(ctx context.Context, userID string, roleIDs []string) error { + body := map[string]interface{}{"roleIds": roleIDs} + path := fmt.Sprintf("/api/users/%s/roles", userID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// ListUserRoles returns the Logto-platform roles assigned to a user. +func (c *Client) ListUserRoles(ctx context.Context, userID string) ([]Role, error) { + var roles []Role + path := fmt.Sprintf("/api/users/%s/roles?page_size=100", userID) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &roles); err != nil { + return nil, err + } + return roles, nil +} diff --git a/api/logto/client.go b/api/logto/client.go new file mode 100644 index 0000000..ce9c3a7 --- /dev/null +++ b/api/logto/client.go @@ -0,0 +1,149 @@ +// Package logto wraps the self-hosted Logto Management API: cached M2M +// access tokens plus authenticated JSON calls. +package logto + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// tokenSafetyMargin is subtracted from expires_in so we refresh ahead of real expiry. +const tokenSafetyMargin = 10 * time.Second +const defaultHTTPTimeout = 10 * time.Second + +// Config carries the inputs NewClient needs. Endpoint is the Logto core URL +// without trailing slash. ManagementAPIResource is the OAuth resource +// indicator (audience); for self-hosted Logto it is the fixed value +// "https://default.logto.app/api" -- not a real URL, just Logto's built-in +// management API audience. HTTPClient is optional. +type Config struct { + Endpoint, ManagementAPIAppID, ManagementAPIAppSecret, ManagementAPIResource string + HTTPClient *http.Client +} + +// Client is a thin wrapper around the Logto Management API. Safe for +// concurrent use; the cached management token is mutex-protected. +type Client struct { + cfg Config + http *http.Client + mu sync.Mutex + cachedToken string + cachedUntil time.Time +} + +// NewClient builds a Client from cfg, defaulting HTTPClient to a 10s timeout. +func NewClient(cfg Config) *Client { + hc := cfg.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: defaultHTTPTimeout} + } + return &Client{cfg: cfg, http: hc} +} + +// APIError is returned for non-2xx Management API responses. errors.As to +// branch on Status (e.g. 404). +type APIError struct { + Status int + Body string +} + +func (e *APIError) Error() string { return fmt.Sprintf("logto API status %d: %s", e.Status, e.Body) } + +// GetManagementToken returns a valid M2M access token, minting a new one +// via OAuth2 client_credentials when the cached value is missing or +// expired. The mutex is held across the network round-trip; token requests +// are infrequent so refresh-stampede coalescing is desirable. +func (c *Client) GetManagementToken(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.cachedToken != "" && time.Now().Before(c.cachedUntil) { + return c.cachedToken, nil + } + + form := url.Values{"grant_type": {"client_credentials"}, "resource": {c.cfg.ManagementAPIResource}, "scope": {"all"}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.cfg.Endpoint+"/oidc/token", strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.cfg.ManagementAPIAppID, c.cfg.ManagementAPIAppSecret) + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read token response: %w", err) + } + if resp.StatusCode >= 400 { + return "", &APIError{Status: resp.StatusCode, Body: string(body)} + } + var parsed struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return "", fmt.Errorf("decode token response: %w", err) + } + if parsed.AccessToken == "" { + return "", fmt.Errorf("token response missing access_token") + } + c.cachedToken = parsed.AccessToken + c.cachedUntil = time.Now().Add(time.Duration(parsed.ExpiresIn)*time.Second - tokenSafetyMargin) + return c.cachedToken, nil +} + +// doJSON issues an authenticated Management API request. body, if non-nil, +// is JSON-encoded with Content-Type: application/json. out, if non-nil, +// receives the JSON-decoded response. Non-2xx responses become *APIError. +func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) error { + token, err := c.GetManagementToken(ctx) + if err != nil { + return err + } + var reqBody io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode request: %w", err) + } + reqBody = bytes.NewReader(buf) + } + req, err := http.NewRequestWithContext(ctx, method, c.cfg.Endpoint+path, reqBody) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if resp.StatusCode >= 400 { + return &APIError{Status: resp.StatusCode, Body: string(respBody)} + } + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} diff --git a/api/logto/client_test.go b/api/logto/client_test.go new file mode 100644 index 0000000..63867ff --- /dev/null +++ b/api/logto/client_test.go @@ -0,0 +1,119 @@ +package logto_test + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/court-command/court-command/logto" + "github.com/stretchr/testify/require" +) + +// fakeLogto returns a test server that mimics the two Management API +// endpoints we exercise: POST /oidc/token and GET /api/users/{id}. It +// counts token requests via the supplied atomic and verifies the request +// shape inline (form values, basic auth) so individual tests stay terse. +func fakeLogto(t *testing.T, tokenCount *atomic.Int64, expiresIn int, userHandler http.HandlerFunc) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/oidc/token", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + require.Equal(t, "client_credentials", r.Form.Get("grant_type")) + require.Equal(t, "https://default.logto.app/api", r.Form.Get("resource")) + require.Equal(t, "all", r.Form.Get("scope")) + + authz := r.Header.Get("Authorization") + require.True(t, strings.HasPrefix(authz, "Basic "), "expected Basic auth, got %q", authz) + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authz, "Basic ")) + require.NoError(t, err) + require.Equal(t, "app:secret", string(raw)) + + tokenCount.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"access_token":"tok-%d","token_type":"Bearer","expires_in":%d}`, tokenCount.Load(), expiresIn) + }) + if userHandler != nil { + mux.HandleFunc("/api/users/", userHandler) + } + return httptest.NewServer(mux) +} + +func newTestClient(endpoint string) *logto.Client { + return logto.NewClient(logto.Config{ + Endpoint: endpoint, + ManagementAPIAppID: "app", + ManagementAPIAppSecret: "secret", + ManagementAPIResource: "https://default.logto.app/api", + }) +} + +func TestClient_GetManagementToken_CachesBetweenCalls(t *testing.T) { + var count atomic.Int64 + srv := fakeLogto(t, &count, 3600, nil) + defer srv.Close() + + c := newTestClient(srv.URL) + ctx := context.Background() + + for i := 0; i < 3; i++ { + tok, err := c.GetManagementToken(ctx) + require.NoError(t, err) + require.NotEmpty(t, tok) + } + + require.Equal(t, int64(1), count.Load(), "expected token to be minted once and reused") +} + +func TestClient_GetManagementToken_RefreshesAfterExpiry(t *testing.T) { + var count atomic.Int64 + // expires_in=1s with a 10s safety margin yields a cachedUntil in the + // past, so the second call must refresh without any wall-clock wait. + srv := fakeLogto(t, &count, 1, nil) + defer srv.Close() + + c := newTestClient(srv.URL) + ctx := context.Background() + + tok1, err := c.GetManagementToken(ctx) + require.NoError(t, err) + + tok2, err := c.GetManagementToken(ctx) + require.NoError(t, err) + + require.GreaterOrEqual(t, count.Load(), int64(2), "expected refresh after expiry") + require.NotEqual(t, tok1, tok2, "refreshed token should differ from initial token") +} + +func TestClient_GetUser_ReturnsAPIErrorOn404(t *testing.T) { + var count atomic.Int64 + const errBody = `{"code":"user.not_found","message":"User not found"}` + + userHandler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/users/missing", r.URL.Path) + require.Equal(t, "Bearer tok-1", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(errBody)) + } + srv := fakeLogto(t, &count, 3600, userHandler) + defer srv.Close() + + c := newTestClient(srv.URL) + user, err := c.GetUser(context.Background(), "missing") + require.Nil(t, user) + require.Error(t, err) + + var apiErr *logto.APIError + require.True(t, errors.As(err, &apiErr), "expected *logto.APIError, got %T: %v", err, err) + require.Equal(t, http.StatusNotFound, apiErr.Status) + require.Contains(t, apiErr.Body, "user.not_found") + require.Contains(t, apiErr.Body, "User not found") +} diff --git a/api/logto/hooks.go b/api/logto/hooks.go new file mode 100644 index 0000000..4b024aa --- /dev/null +++ b/api/logto/hooks.go @@ -0,0 +1,58 @@ +package logto + +import ( + "context" + "net/http" +) + +// Hook is a Logto webhook subscription. Events fire HTTP POST requests +// signed with the hook's signingKey (HMAC-SHA256, header +// `logto-signature-sha-256`). +type Hook struct { + ID string `json:"id"` + Name string `json:"name"` + Events []string `json:"events"` + Config map[string]interface{} `json:"config"` + SigningKey string `json:"signingKey,omitempty"` + Enabled bool `json:"enabled"` +} + +// CreateHookParams is the body for POST /api/hooks. +type CreateHookParams struct { + Name string `json:"name"` + Events []string `json:"events"` + Config map[string]interface{} `json:"config"` + Enabled bool `json:"enabled"` +} + +// CreateHook registers a new webhook subscription. +func (c *Client) CreateHook(ctx context.Context, p CreateHookParams) (*Hook, error) { + var h Hook + if err := c.doJSON(ctx, http.MethodPost, "/api/hooks", p, &h); err != nil { + return nil, err + } + return &h, nil +} + +// ListHooks returns all webhook subscriptions on the tenant. +func (c *Client) ListHooks(ctx context.Context) ([]Hook, error) { + var hooks []Hook + if err := c.doJSON(ctx, http.MethodGet, "/api/hooks?page_size=100", nil, &hooks); err != nil { + return nil, err + } + return hooks, nil +} + +// FindHookByName returns the first hook with the given name, or nil. +func (c *Client) FindHookByName(ctx context.Context, name string) (*Hook, error) { + hooks, err := c.ListHooks(ctx) + if err != nil { + return nil, err + } + for i := range hooks { + if hooks[i].Name == name { + return &hooks[i], nil + } + } + return nil, nil +} diff --git a/api/logto/impersonation.go b/api/logto/impersonation.go new file mode 100644 index 0000000..20446d3 --- /dev/null +++ b/api/logto/impersonation.go @@ -0,0 +1,54 @@ +package logto + +import ( + "context" + "net/http" +) + +// SubjectTokenParams is the body for POST /api/subject-tokens. +// +// UserID is the Logto user ID being impersonated (the eventual `sub` of the +// exchanged access token). Context is an optional free-form object Logto +// embeds in the impersonation token's actor context -- Court Command uses it +// to carry the impersonator's identity and an audit reason so the signal is +// visible end-to-end (in Logto's logs, in the issued token, and in our +// activity_logs row). +type SubjectTokenParams struct { + UserID string `json:"userId"` + Context map[string]interface{} `json:"context,omitempty"` +} + +// SubjectToken is the response of POST /api/subject-tokens. The subjectToken +// is a single-use, short-lived (~10 min) opaque token the frontend exchanges +// at Logto's /oidc/token endpoint with +// grant_type=urn:ietf:params:oauth:grant-type:token-exchange to receive an +// access token whose `sub` is the impersonated user and whose `act.sub` is the +// impersonating admin (RFC 8693 actor claim). +type SubjectToken struct { + SubjectToken string `json:"subjectToken"` +} + +// CreateSubjectToken mints a subject token for impersonating userID via the +// Logto Management API (POST /api/subject-tokens). This is step 1 of the +// OAuth 2.0 Token Exchange impersonation flow (RFC 8693, see Logto docs: +// https://docs.logto.io/developers/user-impersonation). +// +// The returned subject token is NOT itself an access token -- it must be +// exchanged by the SPA (which holds the admin's actor token) at Logto's +// /oidc/token endpoint. The exchange is intentionally performed client-side +// so the impersonated access token never transits Court Command's backend; +// the backend only authorizes the request, mints the subject token, and +// writes the audit-log entry. +// +// ctxData is embedded as the subject-token `context` for audit visibility; +// pass nil if there is nothing to attach. +func (c *Client) CreateSubjectToken(ctx context.Context, userID string, ctxData map[string]interface{}) (*SubjectToken, error) { + var out SubjectToken + if err := c.doJSON(ctx, http.MethodPost, "/api/subject-tokens", SubjectTokenParams{ + UserID: userID, + Context: ctxData, + }, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/api/logto/jwt_customizer.go b/api/logto/jwt_customizer.go new file mode 100644 index 0000000..10a2946 --- /dev/null +++ b/api/logto/jwt_customizer.go @@ -0,0 +1,68 @@ +// Logto access-token JWT customizer. Logto lets a tenant register a single +// JavaScript "custom JWT claims" function per token type that runs at token +// issuance and returns extra claims to merge into the token. The Mgmt API +// surface for the access-token customizer is: +// +// PUT /api/configs/jwt-customizer/access-token -- upsert the script + samples +// +// Court Command uses this to emit the RFC 8693 `act` (actor) claim on the +// access tokens minted via OAuth 2.0 Token Exchange (admin impersonation). +// Logto's token-exchange grant carries the subject-token context through to +// the customizer as `context.grant.subjectTokenContext`; without a customizer +// that copies those fields into `act`, the issued impersonation token has NO +// `act` claim and the backend (api/auth/context.go actorSubject) and SPA +// impersonation banner never detect the impersonation. + +package logto + +import ( + "context" + "net/http" +) + +// AccessTokenJWTCustomizerScript is the JavaScript getCustomJwtClaims function +// installed as the access-token JWT customizer. On token-exchange grants it +// copies the impersonator identity out of the subject-token context into an +// RFC 8693 `act` claim; act.sub is the impersonator's Logto user ID (a STRING, +// as both api/auth/context.go:actorSubject and the SPA expect). It returns no +// extra claims on every other grant type. +const AccessTokenJWTCustomizerScript = `const getCustomJwtClaims = async ({ token, context }) => { if (context.grant && context.grant.type === 'urn:ietf:params:oauth:grant-type:token-exchange') { const ctx = context.grant.subjectTokenContext || {}; return { act: { sub: ctx.impersonator_logto_id, public_id: ctx.impersonator_public_id, reason: ctx.reason } }; } return {}; };` + +// JWTCustomizerParams is the body for PUT /api/configs/jwt-customizer/access-token. +// +// ContextSample is REQUIRED by Logto's Zod guard even though it is only used to +// validate the script in the Console test runner -- omitting context.user (or +// passing an empty contextSample) returns HTTP 400. The grant sample mirrors +// the real token-exchange context shape so an operator testing the script in +// the Console sees a representative payload. +type JWTCustomizerParams struct { + Script string `json:"script"` + EnvironmentVariables map[string]interface{} `json:"environmentVariables"` + ContextSample map[string]interface{} `json:"contextSample"` +} + +// UpsertAccessTokenJWTCustomizer installs (or replaces) the access-token JWT +// customizer script. PUT is an upsert, so this is idempotent -- the seeder +// always PUTs the current script. See AccessTokenJWTCustomizerScript for what +// the installed script does and why. +func (c *Client) UpsertAccessTokenJWTCustomizer(ctx context.Context) error { + return c.doJSON(ctx, http.MethodPut, "/api/configs/jwt-customizer/access-token", JWTCustomizerParams{ + Script: AccessTokenJWTCustomizerScript, + EnvironmentVariables: map[string]interface{}{}, + ContextSample: map[string]interface{}{ + // context.user is required by Logto's Zod guard (HTTP 400 otherwise). + "user": map[string]interface{}{ + "id": "sample", + "primaryEmail": "sample@example.com", + }, + "grant": map[string]interface{}{ + "type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subjectTokenContext": map[string]interface{}{ + "impersonator_logto_id": "sample", + "impersonator_public_id": "CC-00000", + "reason": "court_command_admin_impersonation", + }, + }, + }, + }, nil) +} diff --git a/api/logto/livesmoke_test.go b/api/logto/livesmoke_test.go new file mode 100644 index 0000000..02da59b --- /dev/null +++ b/api/logto/livesmoke_test.go @@ -0,0 +1,64 @@ +// Package logto live smoke test against the real Logto deployment. +// Skipped unless LOGTO_LIVE_SMOKE=1 is set so it never runs in CI / regular dev. +package logto + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestLiveSmoke_GetUser exercises the client end-to-end against the real +// Logto deployment configured via LOGTO_* env vars (.env). Useful as a +// one-shot integration check that token caching, audience handling, and +// error paths all match the real server's behavior. +func TestLiveSmoke_GetUser(t *testing.T) { + if os.Getenv("LOGTO_LIVE_SMOKE") != "1" { + t.Skip("set LOGTO_LIVE_SMOKE=1 to run live smoke against Logto") + } + cfg := Config{ + Endpoint: os.Getenv("LOGTO_ENDPOINT"), + ManagementAPIAppID: os.Getenv("LOGTO_MANAGEMENT_API_APP_ID"), + ManagementAPIAppSecret: os.Getenv("LOGTO_MANAGEMENT_API_APP_SECRET"), + ManagementAPIResource: os.Getenv("LOGTO_MANAGEMENT_API_RESOURCE"), + } + require.NotEmpty(t, cfg.Endpoint, "LOGTO_ENDPOINT must be set") + require.NotEmpty(t, cfg.ManagementAPIAppID, "LOGTO_MANAGEMENT_API_APP_ID must be set") + require.NotEmpty(t, cfg.ManagementAPIAppSecret, "LOGTO_MANAGEMENT_API_APP_SECRET must be set") + require.NotEmpty(t, cfg.ManagementAPIResource, "LOGTO_MANAGEMENT_API_RESOURCE must be set") + + c := NewClient(cfg) + ctx := context.Background() + + // 1. Token mints + tok, err := c.GetManagementToken(ctx) + require.NoError(t, err) + require.NotEmpty(t, tok) + + // 2. Token caches (second call should be the same string) + tok2, err := c.GetManagementToken(ctx) + require.NoError(t, err) + require.Equal(t, tok, tok2) + + // 3. Bootstrap admin lookup (user created in Step 6 of LOGTO_SETUP.md) + bootstrapID := os.Getenv("LOGTO_BOOTSTRAP_USER_ID") + if bootstrapID == "" { + t.Skip("LOGTO_BOOTSTRAP_USER_ID not set; skipping user lookup") + } + user, err := c.GetUser(ctx, bootstrapID) + require.NoError(t, err) + require.Equal(t, bootstrapID, user.ID) + require.NotEmpty(t, user.PrimaryEmail) + t.Logf("bootstrap user resolved: id=%s email=%s name=%s", + user.ID, user.PrimaryEmail, user.Name) + + // 4. 404 path + _, err = c.GetUser(ctx, "nonexistent_user_id_xyz") + require.Error(t, err) + var apiErr *APIError + require.True(t, errors.As(err, &apiErr), "want *APIError, got %T", err) + require.Equal(t, 404, apiErr.Status) +} diff --git a/api/logto/organizations.go b/api/logto/organizations.go new file mode 100644 index 0000000..45dc56d --- /dev/null +++ b/api/logto/organizations.go @@ -0,0 +1,201 @@ +package logto + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +// Organization represents a Logto organization. Court Command uses one org +// per sport (Pickleball, Demo Sport). +type Organization struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CustomData map[string]interface{} `json:"customData,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` +} + +// CreateOrganization creates a new Logto organization. Use FindOrgByName +// first if you need idempotent semantics. +func (c *Client) CreateOrganization(ctx context.Context, name, description string) (*Organization, error) { + body := map[string]interface{}{"name": name} + if description != "" { + body["description"] = description + } + var o Organization + if err := c.doJSON(ctx, http.MethodPost, "/api/organizations", body, &o); err != nil { + return nil, err + } + return &o, nil +} + +// ListOrganizations returns all organizations on the tenant. +func (c *Client) ListOrganizations(ctx context.Context) ([]Organization, error) { + var orgs []Organization + if err := c.doJSON(ctx, http.MethodGet, "/api/organizations?page_size=100", nil, &orgs); err != nil { + return nil, err + } + return orgs, nil +} + +// FindOrgByName returns the first organization matching name exactly, or nil. +func (c *Client) FindOrgByName(ctx context.Context, name string) (*Organization, error) { + orgs, err := c.ListOrganizations(ctx) + if err != nil { + return nil, err + } + for i := range orgs { + if orgs[i].Name == name { + return &orgs[i], nil + } + } + return nil, nil +} + +// AddUserToOrganization adds a user to a Logto organization. Idempotent: +// Logto's POST /organizations/:id/users responds 201 on first add and +// 200/204 on subsequent calls (current behavior; if Logto starts returning +// 422 on duplicates, callers should errors.As for *APIError and ignore 422). +func (c *Client) AddUserToOrganization(ctx context.Context, orgID, userID string) error { + body := map[string]interface{}{"userIds": []string{userID}} + path := fmt.Sprintf("/api/organizations/%s/users", orgID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// AssignOrganizationRolesToUser binds the given organization roles to the +// user's membership in an org. roleNames are the human-readable names +// (e.g. "platform_admin") -- Logto's API actually wants role IDs, so the +// caller must resolve names to IDs via ListOrganizationRoles first. The +// seeder uses this directly with already-resolved IDs. +func (c *Client) AssignOrganizationRolesToUser(ctx context.Context, orgID, userID string, roleIDs []string) error { + body := map[string]interface{}{"organizationRoleIds": roleIDs} + path := fmt.Sprintf("/api/organizations/%s/users/%s/roles", orgID, userID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// GetUserOrganizationRoles returns the organization role names a user +// currently holds within a specific organization. Wraps +// GET /api/organizations/{orgId}/users/{userId}/roles. +// +// Why this exists: Logto does not include the organization_roles claim +// in API-resource access tokens (it only goes into the ID token and +// userinfo endpoint per Logto's design). The api/middleware/jwt_session +// elevation path calls this on every authenticated request that has an +// organization_id claim but no platform_admin role in the local DB -- +// with Redis caching so the hit rate is at most once per (user, org) +// per cache-TTL window. Without this, claims.ElevatedRole() can never +// see platform_admin and admin sidebar / RequirePlatformAdmin gates +// stay closed even for users who legitimately hold the role in Logto. +// +// Returns role names (not IDs), matching the strings the api compares +// against ("platform_admin", "tournament_director", etc.) directly. +// Order is whatever Logto returns; callers should not assume sorting. +// +// A 404 from Logto (org doesn't exist, or user not a member) returns +// (nil, nil) -- the caller treats "no roles" as a valid answer and +// skips elevation. Any other error is returned as-is. +func (c *Client) GetUserOrganizationRoles(ctx context.Context, orgID, userID string) ([]string, error) { + var raw []OrganizationRole + path := fmt.Sprintf("/api/organizations/%s/users/%s/roles", orgID, userID) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &raw); err != nil { + // 404 means org-or-user-or-membership doesn't exist. Treat as + // "user has no roles in this org" rather than propagating -- + // the elevation path falls through to the local DB role, which + // is the correct behavior. + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound { + return nil, nil + } + return nil, err + } + names := make([]string, 0, len(raw)) + for _, r := range raw { + if r.Name != "" { + names = append(names, r.Name) + } + } + return names, nil +} + +// OrganizationRole is a role on the organization template (e.g. +// platform_admin, tournament_director). Distinct from Logto-platform roles +// which gate Management API access. +type OrganizationRole struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` +} + +// ListOrganizationRoles returns all roles defined on the organization +// template. Roles are shared across every organization. +func (c *Client) ListOrganizationRoles(ctx context.Context) ([]OrganizationRole, error) { + var roles []OrganizationRole + if err := c.doJSON(ctx, http.MethodGet, "/api/organization-roles?page_size=100", nil, &roles); err != nil { + return nil, err + } + return roles, nil +} + +// CreateOrganizationRole adds a new role to the organization template. +func (c *Client) CreateOrganizationRole(ctx context.Context, name, description string) (*OrganizationRole, error) { + body := map[string]interface{}{"name": name} + if description != "" { + body["description"] = description + } + var r OrganizationRole + if err := c.doJSON(ctx, http.MethodPost, "/api/organization-roles", body, &r); err != nil { + return nil, err + } + return &r, nil +} + +// OrganizationScope is a permission on the organization template. +type OrganizationScope struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// ListOrganizationScopes returns all org scopes on the template. +func (c *Client) ListOrganizationScopes(ctx context.Context) ([]OrganizationScope, error) { + var scopes []OrganizationScope + if err := c.doJSON(ctx, http.MethodGet, "/api/organization-scopes?page_size=100", nil, &scopes); err != nil { + return nil, err + } + return scopes, nil +} + +// CreateOrganizationScope adds a new scope to the organization template. +func (c *Client) CreateOrganizationScope(ctx context.Context, name, description string) (*OrganizationScope, error) { + body := map[string]interface{}{"name": name} + if description != "" { + body["description"] = description + } + var s OrganizationScope + if err := c.doJSON(ctx, http.MethodPost, "/api/organization-scopes", body, &s); err != nil { + return nil, err + } + return &s, nil +} + +// AssignScopesToOrgRole adds organization scopes to an organization role. +// Both arguments are Logto IDs (not human names). Idempotent: re-adding +// existing scopes is a no-op on most Logto versions. +func (c *Client) AssignScopesToOrgRole(ctx context.Context, roleID string, scopeIDs []string) error { + body := map[string]interface{}{"organizationScopeIds": scopeIDs} + path := fmt.Sprintf("/api/organization-roles/%s/scopes", roleID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// ListOrgRoleScopes returns the scopes currently bound to an org role. +func (c *Client) ListOrgRoleScopes(ctx context.Context, roleID string) ([]OrganizationScope, error) { + var scopes []OrganizationScope + path := fmt.Sprintf("/api/organization-roles/%s/scopes?page_size=100", roleID) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &scopes); err != nil { + return nil, err + } + return scopes, nil +} diff --git a/api/logto/resources.go b/api/logto/resources.go new file mode 100644 index 0000000..0bfc3ff --- /dev/null +++ b/api/logto/resources.go @@ -0,0 +1,91 @@ +package logto + +import ( + "context" + "fmt" + "net/http" +) + +// Resource is a Logto API resource (the "Court Command API" resource that +// JWTs are issued for). Permissions on a resource are called "scopes" in +// the Logto UI; the API endpoint is /api/resources/:id/scopes. +type Resource struct { + ID string `json:"id"` + Name string `json:"name"` + Indicator string `json:"indicator"` + IsDefault bool `json:"isDefault,omitempty"` + AccessTokenTTL int `json:"accessTokenTtl,omitempty"` +} + +// CreateResourceParams is the body for POST /api/resources. +type CreateResourceParams struct { + Name string `json:"name"` + Indicator string `json:"indicator"` +} + +// CreateResource registers a new API resource. The indicator (audience) is +// what JWTs will carry in their `aud` claim and is what api/auth.Validator +// matches against. +func (c *Client) CreateResource(ctx context.Context, p CreateResourceParams) (*Resource, error) { + var r Resource + if err := c.doJSON(ctx, http.MethodPost, "/api/resources", p, &r); err != nil { + return nil, err + } + return &r, nil +} + +// ListResources returns all API resources. Logto returns the built-in +// management-api resource alongside user-created ones. +func (c *Client) ListResources(ctx context.Context) ([]Resource, error) { + var rs []Resource + if err := c.doJSON(ctx, http.MethodGet, "/api/resources?page_size=100", nil, &rs); err != nil { + return nil, err + } + return rs, nil +} + +// FindResourceByIndicator scans resources for an exact indicator match. +// Idempotent companion to CreateResource. +func (c *Client) FindResourceByIndicator(ctx context.Context, indicator string) (*Resource, error) { + rs, err := c.ListResources(ctx) + if err != nil { + return nil, err + } + for i := range rs { + if rs[i].Indicator == indicator { + return &rs[i], nil + } + } + return nil, nil +} + +// Scope is a permission attached to a resource. Maps to the JWT `scope` claim. +type Scope struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ResourceID string `json:"resourceId,omitempty"` +} + +// CreateResourceScope adds a single scope to a resource. Idempotent +// callers should use ListResourceScopes first. +func (c *Client) CreateResourceScope(ctx context.Context, resourceID, name, description string) (*Scope, error) { + body := map[string]interface{}{"name": name, "description": description} + var s Scope + path := fmt.Sprintf("/api/resources/%s/scopes", resourceID) + if err := c.doJSON(ctx, http.MethodPost, path, body, &s); err != nil { + return nil, err + } + return &s, nil +} + +// ListResourceScopes returns the scopes registered on a resource. +// page_size is capped at 100 by Logto's koa-pagination middleware. +func (c *Client) ListResourceScopes(ctx context.Context, resourceID string) ([]Scope, error) { + var scopes []Scope + path := fmt.Sprintf("/api/resources/%s/scopes?page_size=100", resourceID) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &scopes); err != nil { + return nil, err + } + return scopes, nil +} diff --git a/api/logto/signin_experience.go b/api/logto/signin_experience.go new file mode 100644 index 0000000..18f8b6b --- /dev/null +++ b/api/logto/signin_experience.go @@ -0,0 +1,140 @@ +// Logto sign-in experience configuration. Each Logto tenant has exactly +// one sign-in-experience row controlling which identifier types (email, +// username, phone) are accepted, whether sign-up creates new accounts, +// what the password policy is, etc. The Mgmt API surface is: +// +// GET /api/sign-in-exp -- read current config +// PATCH /api/sign-in-exp -- partial update; merged into existing row +// +// The seeder uses PATCH to enable email-as-identifier (default Logto +// install is username-only, which prevents the bootstrap admin from +// signing in with the email the seeder created them with). + +package logto + +import ( + "context" + "net/http" +) + +// SignInIdentifier is one of "username", "email", "phone". +type SignInIdentifier string + +const ( + SignInIdentifierUsername SignInIdentifier = "username" + SignInIdentifierEmail SignInIdentifier = "email" + SignInIdentifierPhone SignInIdentifier = "phone" +) + +// SignInMethod describes how a single identifier type is verified. +// For password-based flows, set Password=true and IsPasswordPrimary=true. +type SignInMethod struct { + Identifier SignInIdentifier `json:"identifier"` + Password bool `json:"password"` + VerificationCode bool `json:"verificationCode"` + IsPasswordPrimary bool `json:"isPasswordPrimary"` +} + +// SignInConfig is the {methods: [...]} block under sign_in. +type SignInConfig struct { + Methods []SignInMethod `json:"methods"` +} + +// SignUpConfig controls whether new accounts can self-register and +// which identifier(s) the registration form requires. +type SignUpConfig struct { + // Identifiers is a list of identifier types users must supply at sign-up. + // e.g. ["email"] requires email, ["username"] requires username. + Identifiers []SignInIdentifier `json:"identifiers"` + // Password controls whether a password is collected at sign-up. + Password bool `json:"password"` + // Verify controls whether the identifier (e.g. email link) is verified. + Verify bool `json:"verify"` +} + +// UpdateSignInExperienceParams is the body for PATCH /api/sign-in-exp. +// All fields are optional; nil leaves the corresponding section unchanged. +// Note JSON tags use the snake_case Logto wire format. +type UpdateSignInExperienceParams struct { + SignIn *SignInConfig `json:"signIn,omitempty"` + SignUp *SignUpConfig `json:"signUp,omitempty"` +} + +// UpdateSignInExperience patches the tenant's sign-in experience config. +// Default Logto install enables only password+username; this endpoint is +// how the seeder switches to email-as-identifier so the bootstrap admin +// (created with primaryEmail and no username) can actually sign in. +// +// IMPORTANT: enabling an email-based identifier requires an email +// connector to exist in the tenant first; otherwise Logto rejects the +// request with sign_in_experiences.enabled_connector_not_found. Use +// CreateConnector with the mock-email-service connector_id for dev. +func (c *Client) UpdateSignInExperience(ctx context.Context, p UpdateSignInExperienceParams) error { + return c.doJSON(ctx, http.MethodPatch, "/api/sign-in-exp", p, nil) +} + +// Connector is a Logto-registered authentication connector instance +// (one row in the connectors table). Each connector wraps a connector +// factory (identified by ConnectorID, e.g. "mock-email-service") with +// tenant-specific config. +type Connector struct { + ID string `json:"id"` + ConnectorID string `json:"connectorId"` + SyncProfile bool `json:"syncProfile"` + Config map[string]interface{} `json:"config,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// CreateConnectorParams is the body for POST /api/connectors. +type CreateConnectorParams struct { + ConnectorID string `json:"connectorId"` + Config map[string]interface{} `json:"config,omitempty"` +} + +// CreateConnector registers a connector instance for the tenant. For +// example, ConnectorID="mock-email-service" registers the mock email +// connector bundled with Logto for integration testing. +func (c *Client) CreateConnector(ctx context.Context, p CreateConnectorParams) (*Connector, error) { + var out Connector + if err := c.doJSON(ctx, http.MethodPost, "/api/connectors", p, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListConnectors returns all registered connector instances for the tenant. +func (c *Client) ListConnectors(ctx context.Context) ([]Connector, error) { + var connectors []Connector + if err := c.doJSON(ctx, http.MethodGet, "/api/connectors", nil, &connectors); err != nil { + return nil, err + } + return connectors, nil +} + +// UpdateConnectorParams is the body for PATCH /api/connectors/{id}. +// Fields are pointers so that omitting them leaves the existing value +// alone (Logto's PATCH semantics on this endpoint). +type UpdateConnectorParams struct { + Config map[string]interface{} `json:"config,omitempty"` + SyncProfile *bool `json:"syncProfile,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// UpdateConnector patches a registered connector instance's config. +// Used by the seeder to reconfigure the SMTP connector with new +// credentials without having to delete + recreate (which would +// invalidate any in-flight verification codes). +func (c *Client) UpdateConnector(ctx context.Context, instanceID string, p UpdateConnectorParams) (*Connector, error) { + var out Connector + if err := c.doJSON(ctx, http.MethodPatch, "/api/connectors/"+instanceID, p, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteConnector removes a connector instance. Used by the seeder +// when migrating from one connector_id (e.g. dev's http-email) to +// another (prod's simple-mail-transfer-protocol). +func (c *Client) DeleteConnector(ctx context.Context, instanceID string) error { + return c.doJSON(ctx, http.MethodDelete, "/api/connectors/"+instanceID, nil, nil) +} diff --git a/api/logto/users.go b/api/logto/users.go new file mode 100644 index 0000000..4205864 --- /dev/null +++ b/api/logto/users.go @@ -0,0 +1,108 @@ +package logto + +import ( + "context" + "errors" + "net/http" + "net/url" +) + +// LogtoUser is the subset of the Logto user record Court Command consumes. +// Field names match Logto's JSON shape verbatim. CustomData is left as a +// generic map because each subsystem (auth_me, webhooks) reads different +// keys; typed parsing happens at the call site. +type LogtoUser struct { + ID string `json:"id"` + Username string `json:"username,omitempty"` + PrimaryEmail string `json:"primaryEmail,omitempty"` + Name string `json:"name,omitempty"` + Avatar string `json:"avatar,omitempty"` + CustomData map[string]interface{} `json:"customData,omitempty"` + IsSuspended bool `json:"isSuspended,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` + UpdatedAt int64 `json:"updatedAt,omitempty"` +} + +// GetUser fetches a single user by Logto user ID. Non-2xx responses are +// returned as *APIError; callers branching on "user not found" should +// errors.As to (*APIError) and check Status == 404. +func (c *Client) GetUser(ctx context.Context, userID string) (*LogtoUser, error) { + var u LogtoUser + if err := c.doJSON(ctx, http.MethodGet, "/api/users/"+userID, nil, &u); err != nil { + return nil, err + } + return &u, nil +} + +// CreateUserParams is the body for POST /api/users. +type CreateUserParams struct { + PrimaryEmail string `json:"primaryEmail,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Name string `json:"name,omitempty"` +} + +// CreateUser creates a Logto user (used by the seeder for the bootstrap +// admin and by Phase 4 for tournament staff). The password is plaintext +// here only because Logto needs to hash it server-side; it must never +// be persisted on Court Command's side. +func (c *Client) CreateUser(ctx context.Context, p CreateUserParams) (*LogtoUser, error) { + var u LogtoUser + if err := c.doJSON(ctx, http.MethodPost, "/api/users", p, &u); err != nil { + return nil, err + } + return &u, nil +} + +// FindUserByEmail returns the first user whose primaryEmail matches +// exactly, or nil if no such user. Idempotent companion to CreateUser +// for the seeder. +func (c *Client) FindUserByEmail(ctx context.Context, email string) (*LogtoUser, error) { + q := url.Values{} + q.Set("search.primaryEmail", email) + q.Set("mode.primaryEmail", "exact") + q.Set("page_size", "10") + + var users []LogtoUser + path := "/api/users?" + q.Encode() + if err := c.doJSON(ctx, http.MethodGet, path, nil, &users); err != nil { + // Some Logto versions don't support search.primaryEmail; fall back + // to a broader scan if that's the case. + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.Status == 400 { + return c.findUserByEmailFallback(ctx, email) + } + return nil, err + } + for i := range users { + if users[i].PrimaryEmail == email { + return &users[i], nil + } + } + return nil, nil +} + +// findUserByEmailFallback scans all users when search.primaryEmail isn't +// honored (older Logto versions). Caps at 1000 users; the seeder targets +// dev environments so this is fine. +func (c *Client) findUserByEmailFallback(ctx context.Context, email string) (*LogtoUser, error) { + const pageSize = 100 + for page := 1; page <= 10; page++ { + q := url.Values{} + q.Set("page", "1") + q.Set("page_size", "100") + var users []LogtoUser + if err := c.doJSON(ctx, http.MethodGet, "/api/users?"+q.Encode(), nil, &users); err != nil { + return nil, err + } + for i := range users { + if users[i].PrimaryEmail == email { + return &users[i], nil + } + } + if len(users) < pageSize { + return nil, nil + } + } + return nil, nil +} diff --git a/api/logtoseed/config.go b/api/logtoseed/config.go new file mode 100644 index 0000000..f70e220 --- /dev/null +++ b/api/logtoseed/config.go @@ -0,0 +1,178 @@ +// Package logtoseed provisions a Logto tenant with everything Court +// Command needs: SPA app, API resource + scopes, M2M role assignment, +// org template (roles + scopes), Pickleball + Demo Sport orgs, +// bootstrap admin with platform_admin in those orgs, the webhook, +// the email connector, the sign-in experience, and a User-type role +// bound to all 12 API scopes assigned to the bootstrap admin. After +// provisioning Logto it syncs sports.logto_org_id in the application +// database to the IDs Logto just generated. +// +// Run is idempotent: every create call is preceded by a find call, so +// invoking it on an already-seeded tenant is safe and (modulo a few +// HTTP round trips) cheap. The api invokes Run on every boot so a +// fresh deploy comes up fully configured without any operator action. +// The api/cmd/logto-seed binary is kept as a CLI fallback for +// debugging or emergencies. +package logtoseed + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Config is everything Run needs to provision a Logto tenant. All +// values are passed in -- callers (api/main.go and the CLI) decide +// how to source them. LoadConfigFromEnv is provided as the canonical +// way to populate this from the standard env vars. +type Config struct { + // Logto Management API connectivity. Required. + Endpoint string // LOGTO_ENDPOINT, e.g. https://logto.courtcommand.app + MgmtAppID string // LOGTO_MANAGEMENT_API_APP_ID + MgmtAppSecret string // LOGTO_MANAGEMENT_API_APP_SECRET + MgmtAPIResource string // LOGTO_MANAGEMENT_API_RESOURCE; defaults to https://default.logto.app/api + + // What we're provisioning. + APIResourceIndicator string // LOGTO_API_RESOURCE; the audience the SPA's tokens target + SPARedirectURI string // LOGTO_SPA_REDIRECT_URI + WebhookURL string // LOGTO_WEBHOOK_URL; where Logto POSTs user.* events + + // Bootstrap admin. The seeder ensures this user exists in Logto and + // has platform_admin in every sport org. + BootstrapEmail string // LOGTO_BOOTSTRAP_EMAIL + BootstrapPassword string // LOGTO_BOOTSTRAP_PASSWORD; only used when creating the user + BootstrapName string // LOGTO_BOOTSTRAP_NAME + + // Drift-protection inputs. When non-empty the seeder treats the env + // value as authoritative and refuses to silently regenerate -- this + // prevents the api from booting against a Logto where someone deleted + // the SPA app or webhook (which would otherwise cause the seeder to + // create a new one with a new ID/key, leaving baked-in build args + // or env stale and the production stack broken). + // + // Empty means "no expectation; create or adopt whatever Logto returns + // from find-by-name". This is the first-boot path on a brand-new + // tenant. + ExpectedSPAAppID string // LOGTO_SPA_APP_ID (== VITE_LOGTO_APP_ID baked into web) + ExpectedWebhookSigningKey string // LOGTO_WEBHOOK_SIGNING_KEY (== api's webhook HMAC env) + + // SeedDemoSport controls whether Demo Sport is created. Defaults to + // true in dev (APP_ENV != "production") and false in production. + // Override with SEED_DEMO_SPORT=true|false. + SeedDemoSport bool + + // SMTP. When all four are set, the seeder registers the SMTP + // connector (real-email path). When empty, registers an http-email + // discard connector (local-dev path; emails go nowhere). + SMTPHost string + SMTPPort int + SMTPUser string + SMTPPass string + EmailFrom string + EmailFromName string + + // EmailVerifyOnSignUp gates whether sign-up requires an email-code + // verification step. Forced false when SMTP is unconfigured (otherwise + // every sign-up would 500 at code-send time). + EmailVerifyOnSignUp bool +} + +// SMTPConfigured reports whether all SMTP fields needed to register a +// real SMTP connector are present. +func (c *Config) SMTPConfigured() bool { + return c.SMTPHost != "" && c.SMTPPort > 0 && c.SMTPUser != "" && c.SMTPPass != "" && c.EmailFrom != "" +} + +// LoadConfigFromEnv reads every Config field from environment variables, +// applying the same defaults as the original CLI. Returns an error if +// any required variable is missing -- callers in production should +// fail-fast on this; dev callers may choose to skip Run entirely. +func LoadConfigFromEnv() (*Config, error) { + cfg := &Config{ + Endpoint: strings.TrimRight(os.Getenv("LOGTO_ENDPOINT"), "/"), + MgmtAppID: os.Getenv("LOGTO_MANAGEMENT_API_APP_ID"), + MgmtAppSecret: os.Getenv("LOGTO_MANAGEMENT_API_APP_SECRET"), + MgmtAPIResource: envOrDefault("LOGTO_MANAGEMENT_API_RESOURCE", "https://default.logto.app/api"), + APIResourceIndicator: envOrDefault("LOGTO_API_RESOURCE", "http://localhost:8080/api"), + SPARedirectURI: envOrDefault("LOGTO_SPA_REDIRECT_URI", "http://localhost:5173/auth/callback"), + WebhookURL: envOrDefault("LOGTO_WEBHOOK_URL", "http://host.docker.internal:8080/api/v1/webhooks/logto"), + BootstrapEmail: envOrDefault("LOGTO_BOOTSTRAP_EMAIL", "admin@courtcommand.local"), + BootstrapPassword: envOrDefault("LOGTO_BOOTSTRAP_PASSWORD", "TestPass123!"), + BootstrapName: envOrDefault("LOGTO_BOOTSTRAP_NAME", "Local Admin"), + ExpectedSPAAppID: os.Getenv("LOGTO_SPA_APP_ID"), + ExpectedWebhookSigningKey: os.Getenv("LOGTO_WEBHOOK_SIGNING_KEY"), + SeedDemoSport: seedDemoSportFromEnv(), + SMTPHost: os.Getenv("SMTP_HOST"), + SMTPPort: smtpPortFromEnv(), + SMTPUser: os.Getenv("SMTP_USER"), + SMTPPass: os.Getenv("SMTP_PASS"), + EmailFrom: os.Getenv("EMAIL_FROM"), + EmailFromName: envOrDefault("EMAIL_FROM_NAME", "Court Command"), + EmailVerifyOnSignUp: emailVerifyOnSignUpFromEnv(), + } + var missing []string + if cfg.Endpoint == "" { + missing = append(missing, "LOGTO_ENDPOINT") + } + if cfg.MgmtAppID == "" { + missing = append(missing, "LOGTO_MANAGEMENT_API_APP_ID") + } + if cfg.MgmtAppSecret == "" { + missing = append(missing, "LOGTO_MANAGEMENT_API_APP_SECRET") + } + if len(missing) > 0 { + // Phrased to be useful both to a developer running the CLI and + // to an operator looking at api boot logs. + return nil, fmt.Errorf("missing required env vars: %s\n\nFirst-run setup: open the Logto admin UI, create the\ninitial admin account, then create a Machine-to-Machine application\nnamed %q assigned the %q role. Copy its App ID and App Secret into\nLOGTO_MANAGEMENT_API_APP_ID/SECRET (Coolify env or .env), then redeploy.", + strings.Join(missing, ", "), M2MAppName, MgmtAPIRoleName) + } + return cfg, nil +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// seedDemoSportFromEnv: explicit SEED_DEMO_SPORT wins; otherwise default +// depends on APP_ENV. Production defaults to false (single-sport launch); +// any other env (including "" / "development") seeds both. +func seedDemoSportFromEnv() bool { + if v := os.Getenv("SEED_DEMO_SPORT"); v != "" { + return strings.EqualFold(v, "true") || v == "1" + } + return os.Getenv("APP_ENV") != "production" +} + +// smtpPortFromEnv parses SMTP_PORT, defaulting to 465 (TLS) which works +// for Resend, AWS SES, and most providers. Returns 0 only when SMTP_PORT +// is set to garbage and even then we log + use 465 -- 0 would otherwise +// cause SMTPConfigured() to return false silently. +func smtpPortFromEnv() int { + v := os.Getenv("SMTP_PORT") + if v == "" { + return 465 + } + n, err := strconv.Atoi(v) + if err != nil { + // Not using slog here so this stays usable from the CLI's plain + // log output; the api wraps stdout in slog anyway. + fmt.Fprintf(os.Stderr, "warning: SMTP_PORT=%q is not numeric; using 465\n", v) + return 465 + } + return n +} + +// emailVerifyOnSignUpFromEnv reports whether sign-up should require an +// email-verification code. Explicit EMAIL_VERIFY_ON_SIGNUP=true|false +// wins; otherwise defaults to true (email verification is industry +// standard). Run() forces this to false when SMTP isn't configured. +func emailVerifyOnSignUpFromEnv() bool { + if v := os.Getenv("EMAIL_VERIFY_ON_SIGNUP"); v != "" { + return strings.EqualFold(v, "true") || v == "1" + } + return true +} diff --git a/api/logtoseed/seeder.go b/api/logtoseed/seeder.go new file mode 100644 index 0000000..98cd706 --- /dev/null +++ b/api/logtoseed/seeder.go @@ -0,0 +1,926 @@ +package logtoseed + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/court-command/court-command/logto" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Logto-side names. Exported so the api startup verifier and the CLI's +// summary printer can reference the same identifiers. +const ( + SPAAppName = "Court Command Web" + M2MAppName = "Court Command Backend" + APIResourceName = "Court Command API" + MgmtAPIRoleName = "Logto Management API access" + PickleballOrgName = "Pickleball" + DemoSportOrgName = "Demo Sport" + pickleballOrgDesc = "Production sport on Court Command" + demoSportOrgDesc = "Test organization that exercises the multi-sport plumbing" + courtCommandHookName = "Court Command Backend" + platformAdminRoleName = "platform_admin" + + // apiUserRoleName is a Logto User-type role bound to all 12 API + // resource scopes. Granted to the bootstrap admin so their access + // tokens carry write:profile, manage_*, etc. Without it, the SPA + // will receive a token with NO API scopes and every PATCH/POST + // will 403 even though the user is "platform_admin" in their org. + apiUserRoleName = "Court Command API (all scopes)" + + // advisoryLockKey is the 64-bit constant used for a transaction- + // scoped Postgres advisory lock around Run. When >1 api container + // boots concurrently against the same DB, only one runs the seeder + // at a time; the others wait, then re-run (cheap because every step + // is idempotent and finds existing items). + // + // Picked arbitrarily; the value just has to be stable. Encoded as + // the integer for "logtoseed" (a hash of the package name). + advisoryLockKey int64 = 0x6c6f67746f73642a // "logtosd*" +) + +// apiScopes are the 12 Court Command API scopes registered on the API +// resource. Order is intentional: read before write within each domain. +var apiScopes = []scopeDef{ + {"read:profile", "Read user profile data"}, + {"write:profile", "Update user profile data"}, + {"read:tournaments", "Read tournament data"}, + {"write:tournaments", "Create or update tournaments"}, + {"read:matches", "Read match data"}, + {"write:matches", "Create or update matches and scores"}, + {"read:registrations", "Read registration data"}, + {"write:registrations", "Create or update registrations"}, + {"read:overlay", "Read overlay configuration"}, + {"write:overlay", "Update overlay configuration"}, + {"read:admin", "Read admin-level data"}, + {"write:admin", "Perform admin-level actions"}, +} + +// orgScopes are the five organization-level scopes shared across roles. +var orgScopes = []scopeDef{ + {"manage_tournaments", "Create, update, archive tournaments in this sport"}, + {"manage_matches", "Score, edit, override matches in this sport"}, + {"manage_registrations", "Add, approve, withdraw registrations"}, + {"manage_users", "Manage user roles and statuses within this sport"}, + {"read_all", "Read all sport data (no write)"}, +} + +// orgRoles are the five organization roles defined on the template, +// with their scope assignments. +var orgRoles = []orgRoleDef{ + {Name: "player", Description: "Default role for users in this sport", + Scopes: []string{"read_all"}}, + {Name: "tournament_director", Description: "Can manage tournaments in this sport", + Scopes: []string{"read_all", "manage_tournaments", "manage_registrations"}}, + {Name: "referee", Description: "Can score matches in this sport", + Scopes: []string{"read_all", "manage_matches"}}, + {Name: "scorekeeper", Description: "Same as referee, alternate label for staff naming", + Scopes: []string{"read_all", "manage_matches"}}, + {Name: "platform_admin", Description: "Full platform access within this sport", + Scopes: []string{"read_all", "manage_tournaments", "manage_matches", "manage_registrations", "manage_users"}}, +} + +// hookEvents are the webhook events the backend's webhook handler subscribes to. +var hookEvents = []string{"User.Created", "User.Data.Updated", "User.Deleted"} + +type scopeDef struct { + name, description string +} + +type orgRoleDef struct { + Name string + Description string + Scopes []string +} + +// Result is what Run produces -- the IDs of the things that were found +// or created. The CLI prints these for the operator; the api just logs +// summary counts. Returned even on error so the caller can see partial +// state. +type Result struct { + APIResourceID string + SPAAppID string + OrgScopeIDs map[string]string // scope name -> ID + OrgRoleIDs map[string]string // role name -> ID + PickleballOrgID string + DemoSportOrgID string + BootstrapUserID string + APIUserRoleID string + WebhookSigningKey string + + // SPAAppCreated and WebhookCreated are true when this Run created a + // new resource (versus adopting an existing one). The CLI uses + // these to decide which env values to emphasize in its summary; the + // api uses them to log a loud warning in production because a newly + // created SPA app or webhook means the baked-in VITE_LOGTO_APP_ID / + // LOGTO_WEBHOOK_SIGNING_KEY env values are now stale. + SPAAppCreated bool + WebhookCreated bool +} + +// Run idempotently provisions the Logto tenant and syncs sports.logto_org_id +// in the application DB. Steps: +// +// 1. Sanity-check Management API auth. +// 2. Acquire a Postgres transaction-scoped advisory lock (when pool != nil). +// Prevents concurrent api containers from racing each other on the +// find-or-create steps. +// 3. API resource + scopes. +// 4. SPA app (drift-protected: if cfg.ExpectedSPAAppID is set, the existing +// app must match that ID; refuses to silently create a new one). +// 5. M2M role assignment. +// 6. Org scopes + org roles. +// 7. Pickleball + (optionally) Demo Sport orgs. +// 8. Bootstrap admin: find-or-create user, ensure platform_admin in every +// sport org. +// 9. API User-type role bound to all 12 API scopes, granted to admin. +// 10. Email connector + sign-in experience. +// 11. Webhook (drift-protected: if cfg.ExpectedWebhookSigningKey is set, the +// existing hook's signing key must match; refuses to silently mint a new key). +// 12. Access-token JWT customizer (emits the RFC 8693 act claim on +// impersonation token-exchange grants). PUT is an upsert; always installed. +// 13. Sync sports.logto_org_id (when pool != nil). +// +// Caller is responsible for constructing the *logto.Client and *pgxpool.Pool. +// Pass pool == nil when running without an application database (the CLI +// against a fresh Logto without DATABASE_URL). +func Run(ctx context.Context, cfg *Config, client *logto.Client, pool *pgxpool.Pool) (*Result, error) { + if cfg == nil { + return nil, errors.New("logtoseed.Run: cfg is nil") + } + if client == nil { + return nil, errors.New("logtoseed.Run: client is nil") + } + r := &Result{} + + slog.Info("logto seed starting", + "endpoint", cfg.Endpoint, + "m2m_app_id", cfg.MgmtAppID, + "seed_demo_sport", cfg.SeedDemoSport, + "smtp_configured", cfg.SMTPConfigured(), + "db_sync_enabled", pool != nil, + ) + + if _, err := client.GetManagementToken(ctx); err != nil { + return r, fmt.Errorf("management API token request failed (check LOGTO_MANAGEMENT_API_APP_ID/SECRET): %w", err) + } + + // Acquire the advisory lock if we have a DB pool. pg_advisory_xact_lock + // is held until the transaction ends; we run everything inside one + // transaction so the lock release is automatic. If the lock isn't + // available because another container is mid-Run, this BLOCKS until + // it gets released -- correct behavior since both containers want + // the same end state. + if pool != nil { + conn, err := pool.Acquire(ctx) + if err != nil { + return r, fmt.Errorf("acquire DB connection for advisory lock: %w", err) + } + defer conn.Release() + tx, err := conn.Begin(ctx) + if err != nil { + return r, fmt.Errorf("begin advisory-lock tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() // commit at end; rollback is harmless after commit + + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", advisoryLockKey); err != nil { + return r, fmt.Errorf("acquire advisory lock: %w", err) + } + + if err := runStepsLocked(ctx, cfg, client, tx, r); err != nil { + return r, err + } + + if err := tx.Commit(ctx); err != nil { + return r, fmt.Errorf("commit advisory-lock tx: %w", err) + } + } else { + // CLI-without-DATABASE_URL path: no lock, no DB sync. The + // runStepsLocked function tolerates a nil tx by skipping + // syncSportsOrgIDs. + if err := runStepsLocked(ctx, cfg, client, nil, r); err != nil { + return r, err + } + } + + // Drift warnings: emitted AFTER successful Run so they don't get + // lost in error noise. These are how the operator learns that + // baked-in env values went stale. + if r.SPAAppCreated && cfg.ExpectedSPAAppID == "" { + slog.Warn("logto seed: created NEW SPA app -- bake VITE_LOGTO_APP_ID into the web build", + "new_app_id", r.SPAAppID) + } + if r.WebhookCreated && cfg.ExpectedWebhookSigningKey == "" { + slog.Warn("logto seed: created NEW webhook -- update LOGTO_WEBHOOK_SIGNING_KEY in api env", + "new_signing_key", r.WebhookSigningKey) + } + + slog.Info("logto seed complete", + "pickleball_org_id", r.PickleballOrgID, + "demo_sport_org_id", r.DemoSportOrgID, + "spa_app_id", r.SPAAppID, + "bootstrap_user_id", r.BootstrapUserID, + ) + return r, nil +} + +func runStepsLocked(ctx context.Context, cfg *Config, c *logto.Client, tx pgx.Tx, r *Result) error { + if err := seedAPIResource(ctx, c, cfg, r); err != nil { + return fmt.Errorf("API resource: %w", err) + } + if err := seedSPAApp(ctx, c, cfg, r); err != nil { + return fmt.Errorf("SPA app: %w", err) + } + if err := assignManagementAPIRole(ctx, c, cfg); err != nil { + return fmt.Errorf("M2M role assignment: %w", err) + } + if err := seedOrgScopes(ctx, c, r); err != nil { + return fmt.Errorf("org scopes: %w", err) + } + if err := seedOrgRoles(ctx, c, r); err != nil { + return fmt.Errorf("org roles: %w", err) + } + if err := seedOrganizations(ctx, c, cfg, r); err != nil { + return fmt.Errorf("organizations: %w", err) + } + if err := seedBootstrapAdmin(ctx, c, cfg, r); err != nil { + return fmt.Errorf("bootstrap admin: %w", err) + } + if err := seedAPIUserRole(ctx, c, r); err != nil { + return fmt.Errorf("API user role: %w", err) + } + if err := seedEmailConnector(ctx, c, cfg); err != nil { + return fmt.Errorf("email connector: %w", err) + } + if err := seedSignInExperience(ctx, c, cfg); err != nil { + return fmt.Errorf("sign-in experience: %w", err) + } + if err := seedWebhook(ctx, c, cfg, r); err != nil { + return fmt.Errorf("webhook: %w", err) + } + if err := seedJWTCustomizer(ctx, c); err != nil { + return fmt.Errorf("jwt customizer: %w", err) + } + if tx != nil { + if err := syncSportsOrgIDs(ctx, tx, cfg, r); err != nil { + return fmt.Errorf("sync sports.logto_org_id: %w", err) + } + } + return nil +} + +func seedAPIResource(ctx context.Context, c *logto.Client, cfg *Config, r *Result) error { + existing, err := c.FindResourceByIndicator(ctx, cfg.APIResourceIndicator) + if err != nil { + return err + } + var resID string + if existing != nil { + slog.Info("api resource exists", "indicator", cfg.APIResourceIndicator, "id", existing.ID) + resID = existing.ID + } else { + created, err := c.CreateResource(ctx, logto.CreateResourceParams{ + Name: APIResourceName, + Indicator: cfg.APIResourceIndicator, + }) + if err != nil { + return fmt.Errorf("create resource: %w", err) + } + slog.Info("created api resource", "indicator", cfg.APIResourceIndicator, "id", created.ID) + resID = created.ID + } + r.APIResourceID = resID + + have, err := c.ListResourceScopes(ctx, resID) + if err != nil { + return fmt.Errorf("list scopes: %w", err) + } + haveSet := make(map[string]bool, len(have)) + for _, s := range have { + haveSet[s.Name] = true + } + added := 0 + for _, s := range apiScopes { + if haveSet[s.name] { + continue + } + if _, err := c.CreateResourceScope(ctx, resID, s.name, s.description); err != nil { + return fmt.Errorf("create scope %q: %w", s.name, err) + } + added++ + } + slog.Info("api scopes synced", "existing", len(have), "added", added, "target", len(apiScopes)) + return nil +} + +func seedSPAApp(ctx context.Context, c *logto.Client, cfg *Config, r *Result) error { + existing, err := c.FindApplicationByName(ctx, SPAAppName) + if err != nil { + return err + } + if existing != nil { + // Drift protection: when the operator pinned the expected ID + // (LOGTO_SPA_APP_ID env), refuse to keep going if the existing + // app has a DIFFERENT ID. That would mean someone deleted the + // original and created a new one, which silently breaks the + // web build that bakes in VITE_LOGTO_APP_ID. + if cfg.ExpectedSPAAppID != "" && existing.ID != cfg.ExpectedSPAAppID { + return fmt.Errorf( + "SPA app %q has id=%q but LOGTO_SPA_APP_ID env expects %q -- the SPA app appears to have been recreated; either restore the original id, or unset LOGTO_SPA_APP_ID and rebuild the web image with the new id baked in as VITE_LOGTO_APP_ID", + SPAAppName, existing.ID, cfg.ExpectedSPAAppID) + } + slog.Info("spa app exists", "name", SPAAppName, "id", existing.ID) + r.SPAAppID = existing.ID + // Idempotently ensure token exchange is enabled on the existing app + // (required for admin impersonation via OAuth 2.0 Token Exchange). + return ensureTokenExchange(ctx, c, existing) + } + + // No existing app. If the operator pinned an expected ID this is a + // hard error -- they're trusting an ID that doesn't exist on the + // tenant. We don't auto-create because that would issue a fresh ID + // and silently invalidate every SPA that was built against the + // expected one. + if cfg.ExpectedSPAAppID != "" { + return fmt.Errorf( + "SPA app %q not found and LOGTO_SPA_APP_ID=%q is set -- the app appears to have been deleted; either restore it in Logto Console with that exact id, or unset LOGTO_SPA_APP_ID to allow the seeder to create a fresh one (this requires rebuilding the web image)", + SPAAppName, cfg.ExpectedSPAAppID) + } + + created, err := c.CreateApplication(ctx, logto.CreateApplicationParams{ + Name: SPAAppName, + Type: logto.AppTypeSPA, + Description: "Court Command web frontend (React SPA)", + OIDCClientMetadata: map[string]interface{}{ + "redirectUris": []string{cfg.SPARedirectURI}, + "postLogoutRedirectUris": []string{trimAuthCallback(cfg.SPARedirectURI)}, + }, + }) + if err != nil { + return fmt.Errorf("create SPA app: %w", err) + } + slog.Info("created spa app", "name", SPAAppName, "id", created.ID) + r.SPAAppID = created.ID + r.SPAAppCreated = true + return ensureTokenExchange(ctx, c, created) +} + +// ensureTokenExchange flips customClientMetadata.allowTokenExchange to true on +// the SPA app when it isn't already set. This is the one-time toggle Logto +// requires before a client may use grant_type=token-exchange, which the admin +// impersonation flow depends on (subject-token exchange at /oidc/token, see +// docs/FEATURES.md §20). Idempotent: a no-op when the flag is already true. +// +// CustomClientMetadata is replaced wholesale by Logto on PATCH, so we start +// from the existing map (if any) and only add our key, preserving any other +// metadata an operator may have set in the Logto Console. +func ensureTokenExchange(ctx context.Context, c *logto.Client, app *logto.Application) error { + if app.CustomClientMetadata != nil { + if v, ok := app.CustomClientMetadata["allowTokenExchange"].(bool); ok && v { + slog.Info("spa app token exchange already enabled", "id", app.ID) + return nil + } + } + + merged := map[string]interface{}{} + for k, v := range app.CustomClientMetadata { + merged[k] = v + } + merged["allowTokenExchange"] = true + + if _, err := c.PatchApplication(ctx, app.ID, logto.PatchApplicationParams{ + CustomClientMetadata: merged, + }); err != nil { + return fmt.Errorf("enable token exchange on SPA app: %w", err) + } + slog.Info("enabled token exchange on spa app", "id", app.ID) + return nil +} + +// trimAuthCallback derives the post-logout redirect URI from the +// callback URI by trimming the trailing /auth/callback. Pulled out so +// the operation has a name. +func trimAuthCallback(s string) string { + const suffix = "/auth/callback" + if len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix { + return s[:len(s)-len(suffix)] + } + return s +} + +func assignManagementAPIRole(ctx context.Context, c *logto.Client, cfg *Config) error { + roles, err := c.ListRoles(ctx) + if err != nil { + return fmt.Errorf("list roles: %w", err) + } + var mgmtRoleID string + for _, role := range roles { + if role.Name == MgmtAPIRoleName { + mgmtRoleID = role.ID + break + } + } + if mgmtRoleID == "" { + return fmt.Errorf("built-in role %q not found in Logto -- cannot self-bootstrap M2M role assignment", MgmtAPIRoleName) + } + err = c.AssignApplicationRoles(ctx, cfg.MgmtAppID, []string{mgmtRoleID}) + if err != nil { + var apiErr *logto.APIError + if errors.As(err, &apiErr) && apiErr.Status == 422 { + slog.Info("m2m app already has mgmt api role") + return nil + } + return fmt.Errorf("assign role: %w", err) + } + slog.Info("assigned mgmt api role to m2m app") + return nil +} + +func seedOrgScopes(ctx context.Context, c *logto.Client, r *Result) error { + have, err := c.ListOrganizationScopes(ctx) + if err != nil { + return fmt.Errorf("list org scopes: %w", err) + } + r.OrgScopeIDs = make(map[string]string, len(have)+len(orgScopes)) + for _, s := range have { + r.OrgScopeIDs[s.Name] = s.ID + } + added := 0 + for _, def := range orgScopes { + if _, ok := r.OrgScopeIDs[def.name]; ok { + continue + } + created, err := c.CreateOrganizationScope(ctx, def.name, def.description) + if err != nil { + return fmt.Errorf("create org scope %q: %w", def.name, err) + } + r.OrgScopeIDs[def.name] = created.ID + added++ + } + slog.Info("org scopes synced", "existing", len(have), "added", added) + return nil +} + +func seedOrgRoles(ctx context.Context, c *logto.Client, r *Result) error { + have, err := c.ListOrganizationRoles(ctx) + if err != nil { + return fmt.Errorf("list org roles: %w", err) + } + r.OrgRoleIDs = make(map[string]string, len(have)+len(orgRoles)) + for _, role := range have { + r.OrgRoleIDs[role.Name] = role.ID + } + + added := 0 + for _, def := range orgRoles { + roleID, ok := r.OrgRoleIDs[def.Name] + if !ok { + created, err := c.CreateOrganizationRole(ctx, def.Name, def.Description) + if err != nil { + return fmt.Errorf("create org role %q: %w", def.Name, err) + } + roleID = created.ID + r.OrgRoleIDs[def.Name] = roleID + added++ + } + currentScopes, err := c.ListOrgRoleScopes(ctx, roleID) + if err != nil { + return fmt.Errorf("list scopes for role %q: %w", def.Name, err) + } + currentSet := make(map[string]bool, len(currentScopes)) + for _, s := range currentScopes { + currentSet[s.Name] = true + } + var toAdd []string + for _, scopeName := range def.Scopes { + if currentSet[scopeName] { + continue + } + scopeID, ok := r.OrgScopeIDs[scopeName] + if !ok { + return fmt.Errorf("role %q wants unknown scope %q", def.Name, scopeName) + } + toAdd = append(toAdd, scopeID) + } + if len(toAdd) > 0 { + if err := c.AssignScopesToOrgRole(ctx, roleID, toAdd); err != nil { + return fmt.Errorf("assign scopes to role %q: %w", def.Name, err) + } + } + } + slog.Info("org roles synced", "existing", len(have), "added", added) + return nil +} + +func seedOrganizations(ctx context.Context, c *logto.Client, cfg *Config, r *Result) error { + specs := []struct { + name, desc string + idField *string + }{ + {PickleballOrgName, pickleballOrgDesc, &r.PickleballOrgID}, + } + if cfg.SeedDemoSport { + specs = append(specs, struct { + name, desc string + idField *string + }{DemoSportOrgName, demoSportOrgDesc, &r.DemoSportOrgID}) + } else { + // Adopt an existing Demo Sport org (so DB sync can update its + // row) without creating one. + existing, err := c.FindOrgByName(ctx, DemoSportOrgName) + if err == nil && existing != nil { + slog.Info("demo sport org adopted from prior seed", "id", existing.ID) + r.DemoSportOrgID = existing.ID + } + } + for _, spec := range specs { + existing, err := c.FindOrgByName(ctx, spec.name) + if err != nil { + return fmt.Errorf("find org %q: %w", spec.name, err) + } + if existing != nil { + slog.Info("org exists", "name", spec.name, "id", existing.ID) + *spec.idField = existing.ID + continue + } + created, err := c.CreateOrganization(ctx, spec.name, spec.desc) + if err != nil { + return fmt.Errorf("create org %q: %w", spec.name, err) + } + slog.Info("created org", "name", spec.name, "id", created.ID) + *spec.idField = created.ID + } + return nil +} + +func seedBootstrapAdmin(ctx context.Context, c *logto.Client, cfg *Config, r *Result) error { + user, err := c.FindUserByEmail(ctx, cfg.BootstrapEmail) + if err != nil { + return fmt.Errorf("find user: %w", err) + } + if user != nil { + slog.Info("bootstrap admin exists", "email", cfg.BootstrapEmail, "id", user.ID) + r.BootstrapUserID = user.ID + } else { + created, err := c.CreateUser(ctx, logto.CreateUserParams{ + PrimaryEmail: cfg.BootstrapEmail, + Password: cfg.BootstrapPassword, + Name: cfg.BootstrapName, + }) + if err != nil { + return fmt.Errorf("create user: %w", err) + } + slog.Info("created bootstrap admin", "email", cfg.BootstrapEmail, "id", created.ID) + r.BootstrapUserID = created.ID + } + + platformAdminRoleID, ok := r.OrgRoleIDs[platformAdminRoleName] + if !ok { + return fmt.Errorf("internal error: %q role ID not resolved", platformAdminRoleName) + } + + var orgIDs []string + if r.PickleballOrgID != "" { + orgIDs = append(orgIDs, r.PickleballOrgID) + } + if r.DemoSportOrgID != "" { + orgIDs = append(orgIDs, r.DemoSportOrgID) + } + for _, orgID := range orgIDs { + if err := c.AddUserToOrganization(ctx, orgID, r.BootstrapUserID); err != nil { + var apiErr *logto.APIError + if !(errors.As(err, &apiErr) && apiErr.Status == 422) { + return fmt.Errorf("add user to org %s: %w", orgID, err) + } + } + if err := c.AssignOrganizationRolesToUser(ctx, orgID, r.BootstrapUserID, []string{platformAdminRoleID}); err != nil { + var apiErr *logto.APIError + if !(errors.As(err, &apiErr) && apiErr.Status == 422) { + return fmt.Errorf("assign role in org %s: %w", orgID, err) + } + } + } + slog.Info("bootstrap admin platform_admin assignments", "org_count", len(orgIDs)) + return nil +} + +func seedWebhook(ctx context.Context, c *logto.Client, cfg *Config, r *Result) error { + existing, err := c.FindHookByName(ctx, courtCommandHookName) + if err != nil { + return fmt.Errorf("find hook: %w", err) + } + if existing != nil { + // Drift protection: if the operator pinned the expected signing + // key (LOGTO_WEBHOOK_SIGNING_KEY env), refuse to keep going if + // the existing webhook has a DIFFERENT key. That would mean + // someone recreated the webhook, which silently breaks api + // HMAC verification. + if cfg.ExpectedWebhookSigningKey != "" && existing.SigningKey != cfg.ExpectedWebhookSigningKey { + return fmt.Errorf( + "webhook %q has signingKey != LOGTO_WEBHOOK_SIGNING_KEY -- the webhook appears to have been recreated; either restore the original signing key, or update LOGTO_WEBHOOK_SIGNING_KEY env to the new value (%s) and redeploy", + courtCommandHookName, existing.SigningKey) + } + slog.Info("webhook exists", "name", courtCommandHookName, "id", existing.ID) + r.WebhookSigningKey = existing.SigningKey + return nil + } + + if cfg.ExpectedWebhookSigningKey != "" { + return fmt.Errorf( + "webhook %q not found and LOGTO_WEBHOOK_SIGNING_KEY is set -- the webhook appears to have been deleted; either restore it in Logto Console with the existing signing key, or unset LOGTO_WEBHOOK_SIGNING_KEY to allow the seeder to create a fresh one (this requires updating the env to the new key after Run logs it)", + courtCommandHookName) + } + + created, err := c.CreateHook(ctx, logto.CreateHookParams{ + Name: courtCommandHookName, + Events: hookEvents, + Config: map[string]interface{}{"url": cfg.WebhookURL}, + Enabled: true, + }) + if err != nil { + return fmt.Errorf("create hook: %w", err) + } + slog.Info("created webhook", "name", courtCommandHookName, "id", created.ID, "url", cfg.WebhookURL) + r.WebhookSigningKey = created.SigningKey + r.WebhookCreated = true + return nil +} + +// seedJWTCustomizer installs the access-token JWT customizer that emits the +// RFC 8693 `act` claim on token-exchange (impersonation) grants. Without it, +// the exchanged access token has no `act` claim and neither the backend +// (api/auth/context.go actorSubject) nor the SPA impersonation banner can +// detect impersonation. PUT is an upsert, so we always install the current +// script -- it's idempotent and brings drifted tenants back in line. +func seedJWTCustomizer(ctx context.Context, c *logto.Client) error { + if err := c.UpsertAccessTokenJWTCustomizer(ctx); err != nil { + return fmt.Errorf("install access-token jwt customizer: %w", err) + } + slog.Info("access-token jwt customizer installed (emits act claim on impersonation grants)") + return nil +} + +func seedAPIUserRole(ctx context.Context, c *logto.Client, r *Result) error { + if r.APIResourceID == "" { + return fmt.Errorf("APIResourceID not set -- seedAPIResource must run first") + } + if r.BootstrapUserID == "" { + return fmt.Errorf("BootstrapUserID not set -- seedBootstrapAdmin must run first") + } + role, err := c.FindRoleByName(ctx, apiUserRoleName) + if err != nil { + return fmt.Errorf("find role: %w", err) + } + if role == nil { + created, err := c.CreateRole(ctx, logto.CreateRoleParams{ + Name: apiUserRoleName, + Description: "Grants every Court Command API scope. Assigned to dev/admin users so their JWTs carry write:profile, manage_*, etc.", + Type: "User", + }) + if err != nil { + return fmt.Errorf("create role: %w", err) + } + role = created + slog.Info("created api user role", "name", apiUserRoleName, "id", role.ID) + } else { + slog.Info("api user role exists", "name", apiUserRoleName, "id", role.ID) + } + r.APIUserRoleID = role.ID + + allScopes, err := c.ListResourceScopes(ctx, r.APIResourceID) + if err != nil { + return fmt.Errorf("list resource scopes: %w", err) + } + bound, err := c.ListRoleScopes(ctx, role.ID) + if err != nil { + return fmt.Errorf("list role scopes: %w", err) + } + boundSet := make(map[string]bool, len(bound)) + for _, s := range bound { + boundSet[s.ID] = true + } + var toBind []string + for _, s := range allScopes { + if !boundSet[s.ID] { + toBind = append(toBind, s.ID) + } + } + if len(toBind) > 0 { + if err := c.AssignScopesToRole(ctx, role.ID, toBind); err != nil { + var apiErr *logto.APIError + if errors.As(err, &apiErr) && apiErr.Status == 422 { + slog.Info("api role scope bindings already current (logto returned 422)") + } else { + return fmt.Errorf("assign scopes to role: %w", err) + } + } + } + slog.Info("api role scopes synced", + "existing", len(bound), "added", len(toBind), "target", len(allScopes)) + + if err := c.AssignRolesToUser(ctx, r.BootstrapUserID, []string{role.ID}); err != nil { + var apiErr *logto.APIError + if errors.As(err, &apiErr) && apiErr.Status == 422 { + slog.Info("bootstrap admin already has api user role") + return nil + } + return fmt.Errorf("assign role to user: %w", err) + } + slog.Info("granted api user role to bootstrap admin") + return nil +} + +func seedEmailConnector(ctx context.Context, c *logto.Client, cfg *Config) error { + const ( + httpEmailConnectorID = "http-email" + smtpConnectorID = "simple-mail-transfer-protocol" + ) + existing, err := c.ListConnectors(ctx) + if err != nil { + return fmt.Errorf("list connectors: %w", err) + } + var ( + existingHTTP *logto.Connector + existingSMTP *logto.Connector + ) + for i := range existing { + switch existing[i].ConnectorID { + case httpEmailConnectorID: + existingHTTP = &existing[i] + case smtpConnectorID: + existingSMTP = &existing[i] + } + } + + if !cfg.SMTPConfigured() { + if existingHTTP != nil { + slog.Info("email connector exists (discard endpoint)", "id", existingHTTP.ID) + return nil + } + created, err := c.CreateConnector(ctx, logto.CreateConnectorParams{ + ConnectorID: httpEmailConnectorID, + Config: map[string]interface{}{ + "endpoint": "http://localhost:9999/discard", + }, + }) + if err != nil { + return fmt.Errorf("create http-email connector: %w", err) + } + slog.Info("registered http-email discard connector (no real email)", "id", created.ID) + return nil + } + + smtpConfig := map[string]interface{}{ + "host": cfg.SMTPHost, + "port": cfg.SMTPPort, + "secure": cfg.SMTPPort == 465, + "auth": map[string]interface{}{ + "user": cfg.SMTPUser, + "pass": cfg.SMTPPass, + }, + "fromEmail": cfg.EmailFrom, + "fromName": cfg.EmailFromName, + "templates": defaultEmailTemplates(), + } + + if existingSMTP != nil { + if _, err := c.UpdateConnector(ctx, existingSMTP.ID, logto.UpdateConnectorParams{ + Config: smtpConfig, + }); err != nil { + return fmt.Errorf("update SMTP connector: %w", err) + } + slog.Info("smtp connector reconfigured", + "id", existingSMTP.ID, "host", cfg.SMTPHost, "port", cfg.SMTPPort, "from", cfg.EmailFrom) + } else { + created, err := c.CreateConnector(ctx, logto.CreateConnectorParams{ + ConnectorID: smtpConnectorID, + Config: smtpConfig, + }) + if err != nil { + return fmt.Errorf("create SMTP connector: %w", err) + } + slog.Info("registered smtp connector", + "id", created.ID, "host", cfg.SMTPHost, "port", cfg.SMTPPort, "from", cfg.EmailFrom) + } + + if existingHTTP != nil { + if err := c.DeleteConnector(ctx, existingHTTP.ID); err != nil { + slog.Warn("failed to delete leftover http-email connector", "id", existingHTTP.ID, "error", err) + } else { + slog.Info("removed leftover http-email discard connector", "id", existingHTTP.ID) + } + } + return nil +} + +func defaultEmailTemplates() []map[string]interface{} { + tmpl := func(usageType, subject, intro string) map[string]interface{} { + body := fmt.Sprintf(` + +

Court Command

+

%s

+

{{code}}

+

If you didn't request this code, you can safely ignore this email.

+`, intro) + return map[string]interface{}{ + "usageType": usageType, + "type": "EmailTemplateType.Generic", + "subject": subject, + "content": body, + "contentType": "text/html", + } + } + return []map[string]interface{}{ + tmpl("Register", "Welcome to Court Command — verify your email", "Welcome! Your verification code is:"), + tmpl("SignIn", "Court Command sign-in code", "Your sign-in code is:"), + tmpl("ForgotPassword", "Reset your Court Command password", "Your password reset code is:"), + tmpl("Generic", "Court Command verification code", "Your verification code is:"), + } +} + +func seedSignInExperience(ctx context.Context, c *logto.Client, cfg *Config) error { + verify := cfg.EmailVerifyOnSignUp && cfg.SMTPConfigured() + // Logto rejects an email/phone sign-up identifier unless verification + // is enabled (sign_in_experiences.passwordless_requires_verify). With + // no SMTP connector (local dev) we can't deliver a verification code, + // so fall back to a username sign-up identifier. The bootstrap admin + // still signs in via email+password (configured in SignIn.Methods + // below); only NEW self-registration uses username in this mode. + signUpIdentifier := logto.SignInIdentifierEmail + if !verify { + signUpIdentifier = logto.SignInIdentifierUsername + } + params := logto.UpdateSignInExperienceParams{ + SignIn: &logto.SignInConfig{ + Methods: []logto.SignInMethod{ + { + Identifier: logto.SignInIdentifierEmail, + Password: true, + VerificationCode: cfg.SMTPConfigured(), + IsPasswordPrimary: true, + }, + { + Identifier: logto.SignInIdentifierUsername, + Password: true, + VerificationCode: false, + IsPasswordPrimary: false, + }, + }, + }, + SignUp: &logto.SignUpConfig{ + Identifiers: []logto.SignInIdentifier{signUpIdentifier}, + Password: true, + Verify: verify, + }, + } + if err := c.UpdateSignInExperience(ctx, params); err != nil { + return fmt.Errorf("update sign-in experience: %w", err) + } + slog.Info("sign-in experience configured", "signup_verify", verify, "magic_link", cfg.SMTPConfigured()) + return nil +} + +// syncSportsOrgIDs writes the live org IDs into sports.logto_org_id. +// Runs inside the same transaction that holds the advisory lock so two +// concurrent api boots can't fight over the row. +func syncSportsOrgIDs(ctx context.Context, tx pgx.Tx, cfg *Config, r *Result) error { + if r.PickleballOrgID == "" { + return fmt.Errorf("PickleballOrgID not set -- seedOrganizations must run first") + } + tag, err := tx.Exec(ctx, + "UPDATE sports SET logto_org_id=$1 WHERE slug='pickleball'", + r.PickleballOrgID) + if err != nil { + return fmt.Errorf("update pickleball: %w", err) + } + pickleballRows := tag.RowsAffected() + + var demoRows int64 + if r.DemoSportOrgID != "" { + tag, err := tx.Exec(ctx, + "UPDATE sports SET logto_org_id=$1, is_active=true WHERE slug='demo_sport'", + r.DemoSportOrgID) + if err != nil { + return fmt.Errorf("update demo_sport: %w", err) + } + demoRows = tag.RowsAffected() + } else if !cfg.SeedDemoSport { + // Production launch mode: hide Demo Sport from the picker. + tag, err := tx.Exec(ctx, + "UPDATE sports SET is_active=false WHERE slug='demo_sport'") + if err != nil { + return fmt.Errorf("deactivate demo_sport: %w", err) + } + slog.Info("demo sport hidden from picker", "rows", tag.RowsAffected()) + } + slog.Info("synced sports.logto_org_id", + "pickleball_rows", pickleballRows, "demo_sport_rows", demoRows) + if pickleballRows == 0 && demoRows == 0 { + slog.Warn("zero sports rows updated -- did migrations run?") + } + return nil +} diff --git a/api/main.go b/api/main.go index 68f7ae6..bb50d59 100644 --- a/api/main.go +++ b/api/main.go @@ -10,16 +10,21 @@ import ( "syscall" "time" + "github.com/court-command/court-command/auth" "github.com/court-command/court-command/config" "github.com/court-command/court-command/db" "github.com/court-command/court-command/db/generated" "github.com/court-command/court-command/handler" "github.com/court-command/court-command/jobs" + "github.com/court-command/court-command/logto" + "github.com/court-command/court-command/logtoseed" + "github.com/court-command/court-command/middleware" "github.com/court-command/court-command/overlay" "github.com/court-command/court-command/pubsub" "github.com/court-command/court-command/router" "github.com/court-command/court-command/service" "github.com/court-command/court-command/session" + "github.com/court-command/court-command/startup" "github.com/court-command/court-command/ws" ) @@ -151,13 +156,15 @@ func main() { publicHandler.SetVenueService(venueService) publicHandler.SetSeasonService(seasonService) publicHandler.SetTournamentService(tournamentService) + publicHandler.SetStandingsService(standingsService) // Phase 8: Admin & Platform Management activityLogService := service.NewActivityLogService(queries) apiKeyService := service.NewApiKeyService(queries) uploadService := service.NewUploadService(queries, "uploads") adService := service.NewAdService(queries) - adminHandler := handler.NewAdminHandler(queries, activityLogService, apiKeyService, sessionStore, uploadService) + // adminHandler is constructed below, after logtoClient is built, because + // the Logto-native impersonation endpoint needs the Management API client. uploadHandler := handler.NewUploadHandler(uploadService) adHandler := handler.NewAdHandler(adService) @@ -165,8 +172,207 @@ func main() { settingsService := service.NewSettingsService(pool) settingsHandler := handler.NewSettingsHandler(settingsService) - // Phase 4C: WebSocket handler - wsHandler := ws.NewHandler(ps, logger) + // Logto Phase 3: public sport directory + sportsService := service.NewSportsService(queries) + sportsHandler := handler.NewSportsHandler(sportsService) + + // Logto Phase 3: profile endpoints + JWT validator. The discovery + // document at LOGTO_ENDPOINT/oidc/.well-known/openid-configuration + // publishes the canonical issuer with the /oidc suffix; tokens + // minted by Logto carry that exact iss claim. Building the issuer + // URL by appending "/oidc" matches both self-hosted and cloud Logto + // deployments. JWKS lives at the same /oidc/jwks path on both. + logtoEndpoint := os.Getenv("LOGTO_ENDPOINT") + logtoAPIResource := os.Getenv("LOGTO_API_RESOURCE") + mgmtAppID := os.Getenv("LOGTO_MANAGEMENT_API_APP_ID") + mgmtAppSecret := os.Getenv("LOGTO_MANAGEMENT_API_APP_SECRET") + mgmtResource := os.Getenv("LOGTO_MANAGEMENT_API_RESOURCE") + webhookSigningKey := os.Getenv("LOGTO_WEBHOOK_SIGNING_KEY") + + // Phase 3.6 review I5: fail-fast in production when ANY required + // Logto env var is missing. Pre-3.6 we only checked the four Mgmt + // API vars (I1); this expanded check also covers LOGTO_API_RESOURCE + // (without it, jwtValidator is nil and the SPA's JWT requests 401) + // and LOGTO_WEBHOOK_SIGNING_KEY (without it, webhook deliveries 500 + // at runtime). cfg.IsProduction() treats anything that isn't a + // recognized dev marker as production, so APP_ENV=staging / + // APP_ENV=prod also trip the check. + if cfg.IsProduction() { + var missing []string + if logtoEndpoint == "" { + missing = append(missing, "LOGTO_ENDPOINT") + } + if logtoAPIResource == "" { + missing = append(missing, "LOGTO_API_RESOURCE") + } + if mgmtAppID == "" { + missing = append(missing, "LOGTO_MANAGEMENT_API_APP_ID") + } + if mgmtAppSecret == "" { + missing = append(missing, "LOGTO_MANAGEMENT_API_APP_SECRET") + } + if mgmtResource == "" { + missing = append(missing, "LOGTO_MANAGEMENT_API_RESOURCE") + } + if webhookSigningKey == "" { + missing = append(missing, "LOGTO_WEBHOOK_SIGNING_KEY") + } + if len(missing) > 0 { + slog.Error("required Logto env vars missing in production", + "missing", missing, + "env", cfg.Env) + os.Exit(1) + } + } + + var jwtValidator *auth.Validator + var profileHandler *handler.ProfileHandler + if logtoEndpoint != "" && logtoAPIResource != "" { + jwtValidator = auth.NewValidator( + logtoEndpoint+"/oidc", + logtoEndpoint+"/oidc/jwks", + logtoAPIResource, + ) + profileService := service.NewProfileService(queries) + profileHandler = handler.NewProfileHandler(profileService) + } else { + slog.Warn("LOGTO_ENDPOINT or LOGTO_API_RESOURCE not set; /api/v1/me/profile disabled") + } + + // Logto Phase 3 Task 9: webhook handler + on-demand user mirror. + // + // The webhook is constructed unconditionally (it short-circuits + // to 500 INTERNAL_ERROR if the signing key is empty, so an + // accidental misconfiguration can't silently accept unsigned + // requests). The Logto Management API client is constructed + // only when all four env vars are set; without it MirrorUser + // is left disabled (the router conditionally chains it). + userSyncService := service.NewUserSyncService(queries) + webhookHandler := handler.NewLogtoWebhookHandler( + userSyncService, + webhookSigningKey, + ) + + var logtoClient *logto.Client + if logtoEndpoint != "" && mgmtAppID != "" && mgmtAppSecret != "" && mgmtResource != "" { + logtoClient = logto.NewClient(logto.Config{ + Endpoint: logtoEndpoint, + ManagementAPIAppID: mgmtAppID, + ManagementAPIAppSecret: mgmtAppSecret, + ManagementAPIResource: mgmtResource, + }) + } else { + slog.Warn("Logto Management API env vars missing; on-demand user mirror disabled (dev only)") + } + + // Admin handler depends on logtoClient for Logto-native impersonation + // (subject-token minting). logtoClient may be nil in dev without Mgmt API + // creds; the impersonate endpoint 503s in that case. + adminHandler := handler.NewAdminHandler(queries, activityLogService, apiKeyService, sessionStore, uploadService, logtoClient) + + // OrgRoleResolver bridges the gap between Logto's published token + // behavior and what the api expected. Logto does NOT include the + // organization_roles claim in API-resource access tokens (only in + // ID tokens and userinfo). Without this resolver, + // claims.ElevatedRole() always returns "" and no user ever gets + // elevated to platform_admin -- even though they hold the role in + // Logto Console. The resolver fills that gap by asking the + // Management API for the user's roles on each authenticated + // request, caching in Redis (TTL configurable via + // LOGTO_ORG_ROLES_CACHE_TTL_SECONDS, default 60s) so warm caches + // absorb the bulk of traffic. See + // api/middleware/org_role_resolver.go for the rationale and code. + var orgRoleResolver middleware.OrgRoleResolver + if logtoClient != nil { + orgRoleResolver = middleware.NewLogtoMgmtAPIResolver( + logtoClient, sessionStore.Client(), middleware.OrgRolesCacheTTLFromEnv()) + } + + // Auto-bootstrap the Logto tenant on every boot. This calls the + // same idempotent provisioning logic as the api/cmd/logto-seed CLI: + // - registers the API resource + 12 scopes + // - finds-or-creates the SPA app, refusing to silently regenerate + // when LOGTO_SPA_APP_ID is set (drift protection for the + // baked-in VITE_LOGTO_APP_ID) + // - finds-or-creates the Pickleball + (optionally) Demo Sport orgs + // - ensures LOGTO_BOOTSTRAP_EMAIL exists in Logto and holds + // platform_admin in every sport org + // - registers the email connector + sign-in experience + // - finds-or-creates the webhook, refusing to silently regenerate + // when LOGTO_WEBHOOK_SIGNING_KEY is set + // - syncs sports.logto_org_id in the application DB to match the + // real Logto org IDs (under a Postgres advisory lock) + // + // The end result: a fresh deploy comes up fully configured without + // any manual SQL or seeder runs. Re-running on every boot is cheap + // (~10 idempotent Mgmt API calls; <2 seconds when nothing changes) + // and self-healing for cases like a Logto restore that changes org IDs. + // + // In production a failure here exits the process so the operator + // learns immediately. In development we warn and continue so local + // stacks without M2M creds still come up. No-op when logtoClient + // is nil (Mgmt API creds absent). + if logtoClient != nil { + seedCfg, err := logtoseed.LoadConfigFromEnv() + if err != nil { + if cfg.IsProduction() { + slog.Error("logto seed config", "error", err) + os.Exit(1) + } + slog.Warn("logto seed config (dev mode -- skipping auto-bootstrap)", "error", err) + } else { + if _, err := logtoseed.Run(ctx, seedCfg, logtoClient, pool); err != nil { + if cfg.IsProduction() { + slog.Error("logto auto-bootstrap failed", "error", err) + os.Exit(1) + } + slog.Warn("logto auto-bootstrap failed (dev mode -- continuing)", "error", err) + } + } + } + + // Belt-and-suspenders verification: even after Run succeeds, confirm + // every active sports.logto_org_id resolves to a real Logto org. + // Catches scenarios the seeder couldn't fix (e.g. a manually-added + // sport row pointing at a deleted org, or a partially-applied Run + // that bailed before syncSportsOrgIDs). + if err := startup.VerifySportsOrgIDsFromDB(ctx, pool, logtoClient, cfg.IsProduction()); err != nil { + slog.Error("sports.logto_org_id verification failed", "error", err) + os.Exit(1) + } + + // Build the slug -> Logto-org-ID resolver that backs + // RequireSportMatchesJWT on sport-scoped protected routes. It reads + // the same sports.logto_org_id column the verifier above checks, so + // by this point the IDs are real (the seeder + verifier ran first). + // Placeholder rows (pending-seed:*) are skipped; an unknown slug in + // the resolver yields a 400 from the middleware rather than a silent + // cross-sport bypass. When logtoClient is nil (dev without Mgmt API) + // the JWT path is also disabled, so a nil/empty resolver simply means + // the sport check is never chained -- see router.useAuth. + var sportResolver *middleware.SportResolver + { + activeSports, err := startup.LoadActiveSportsFromDB(ctx, pool) + if err != nil { + slog.Error("load active sports for sport resolver", "error", err) + os.Exit(1) + } + slugToOrgID := make(map[string]string, len(activeSports)) + for _, s := range activeSports { + if s.OrgID == "" || startup.IsPendingSeedPlaceholder(s.OrgID) { + continue + } + slugToOrgID[s.Slug] = s.OrgID + } + sportResolver = middleware.NewSportResolver(slugToOrgID) + slog.Info("sport resolver built", "sports", len(slugToOrgID)) + } + + // Phase 4C: WebSocket handler. CheckOrigin is restricted to the + // configured CORS origins (plus empty-Origin non-browser clients); + // the web origin must be in CORS_ALLOWED_ORIGINS so OBS / browser- + // source overlays can connect. + wsHandler := ws.NewHandler(ps, logger, cfg.CORSAllowedOrigins) // Start background jobs jobs.StartQuickMatchCleanup(ctx, matchService, logger) @@ -233,6 +439,24 @@ func main() { // Phase 4C WSHandler: wsHandler.Routes(), + + // Logto Phase 3 + SportsHandler: sportsHandler, + ProfileHandler: profileHandler, + JWTValidator: jwtValidator, + + // Logto Phase 3 Task 9 + LogtoWebhookHandler: webhookHandler, + LogtoClient: logtoClient, + UserSyncService: userSyncService, + Queries: queries, + + // Mgmt-API-backed elevation: see api/middleware/org_role_resolver.go + OrgRoles: orgRoleResolver, + + // Sport-scoped authz: RequireSportMatchesJWT confirms the JWT's + // organization_id matches the X-Sport the request targets. + SportResolver: sportResolver, }) srv := &http.Server{ diff --git a/api/middleware/auth.go b/api/middleware/auth.go index b0bbb83..7349077 100644 --- a/api/middleware/auth.go +++ b/api/middleware/auth.go @@ -88,6 +88,8 @@ func RequireRole(roles ...string) func(http.Handler) http.Handler { } // RequirePlatformAdmin is middleware that requires the user to be a platform admin. +// Returns 401 when unauthenticated and 403 when the authenticated user's role is +// not platform_admin. func RequirePlatformAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { data := SessionData(r.Context()) diff --git a/api/middleware/cors.go b/api/middleware/cors.go index b5ff4a0..9434cbc 100644 --- a/api/middleware/cors.go +++ b/api/middleware/cors.go @@ -21,7 +21,7 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler { if origin != "" && originSet[origin] { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID, X-Sport") w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Max-Age", "86400") } diff --git a/api/middleware/jwt_middleware.go b/api/middleware/jwt_middleware.go new file mode 100644 index 0000000..76b66e6 --- /dev/null +++ b/api/middleware/jwt_middleware.go @@ -0,0 +1,67 @@ +// api/middleware/jwt_middleware.go +package middleware + +import ( + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/court-command/court-command/auth" +) + +// RequireJWT validates the Authorization Bearer token against the given +// validator on every request. On success, the parsed Claims are stored in +// the request context via auth.WithClaims. On any validation failure the +// middleware writes a 401 JSON error envelope and short-circuits the chain. +// +// orgScoped controls whether the validator accepts tokens whose audience +// is urn:logto:organization:* in addition to the global API resource +// audience. Use true for sport-scoped routes that consume Logto org tokens; +// use false for routes that should only accept the global API audience. +func RequireJWT(v *auth.Validator, orgScoped bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := bearerToken(r.Header.Get("Authorization")) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + + claims, err := v.Validate(r.Context(), token, orgScoped) + if err != nil { + // Distinguish "Logto JWKS is unreachable" (infra failure -> + // 503, log at error) from "client sent a bad token" (routine + // -> 401, log at debug). Conflating these makes operators + // chase JWT bugs while the real cause is networking. + if errors.Is(err, auth.ErrJWKSUnavailable) { + slog.ErrorContext(r.Context(), "jwks unavailable, cannot validate tokens", "err", err) + writeError(w, http.StatusServiceUnavailable, "service_unavailable", + "authentication temporarily unavailable") + return + } + slog.DebugContext(r.Context(), "jwt validation failed", "err", err) + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid token") + return + } + + ctx := auth.WithClaims(r.Context(), claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// bearerToken extracts the token portion of a "Bearer " Authorization +// header value. Returns ("", false) for any non-Bearer scheme, missing token, +// or otherwise malformed header so the caller can short-circuit with 401. +func bearerToken(header string) (string, bool) { + const prefix = "Bearer " + if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return "", false + } + token := strings.TrimSpace(header[len(prefix):]) + if token == "" { + return "", false + } + return token, true +} diff --git a/api/middleware/jwt_middleware_test.go b/api/middleware/jwt_middleware_test.go new file mode 100644 index 0000000..6924a40 --- /dev/null +++ b/api/middleware/jwt_middleware_test.go @@ -0,0 +1,366 @@ +// api/middleware/jwt_middleware_test.go +package middleware_test + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/middleware" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/require" +) + +const ( + testIssuer = "https://test.logto.app/" + testAPIaud = "https://api.courtcommand.app/api" + testKID = "test-key-1" + testOrgURNAud = "urn:logto:organization:org_pickleball" +) + +// testKey returns (privateKey, jwksServerURL) where the JWKS server serves +// the public counterpart of priv as a single-entry JWK Set with kid=testKID +// and alg=RS256. Caller is responsible for calling t.Cleanup-equivalent; +// httptest.Server is registered with t.Cleanup here. +func testKey(t *testing.T) (*rsa.PrivateKey, string) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + jwksURL := serveJWKS(t, &priv.PublicKey, testKID) + return priv, jwksURL +} + +// serveJWKS spins up an httptest.Server that returns a JWK Set containing +// the single public key under kid. The server is torn down via t.Cleanup. +func serveJWKS(t *testing.T, pub *rsa.PublicKey, kid string) string { + t.Helper() + pubJWK, err := jwk.Import(pub) + require.NoError(t, err) + require.NoError(t, pubJWK.Set(jwk.KeyIDKey, kid)) + require.NoError(t, pubJWK.Set(jwk.AlgorithmKey, jwa.RS256())) + + set := jwk.NewSet() + require.NoError(t, set.AddKey(pubJWK)) + body, err := json.Marshal(set) + require.NoError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// mintToken signs a JWT with priv and returns the compact-serialized form. +// Defaults: iss=testIssuer, iat=now, exp=now+5m. Caller-supplied claims +// override the defaults; pass time.Time values for exp/iat. +func mintToken(t *testing.T, priv *rsa.PrivateKey, claims map[string]interface{}) string { + t.Helper() + tok := jwt.New() + + if _, ok := claims[jwt.IssuerKey]; !ok { + require.NoError(t, tok.Set(jwt.IssuerKey, testIssuer)) + } + if _, ok := claims[jwt.IssuedAtKey]; !ok { + require.NoError(t, tok.Set(jwt.IssuedAtKey, time.Now())) + } + if _, ok := claims[jwt.ExpirationKey]; !ok { + require.NoError(t, tok.Set(jwt.ExpirationKey, time.Now().Add(5*time.Minute))) + } + for k, v := range claims { + require.NoError(t, tok.Set(k, v)) + } + + privJWK, err := jwk.Import(priv) + require.NoError(t, err) + require.NoError(t, privJWK.Set(jwk.KeyIDKey, testKID)) + require.NoError(t, privJWK.Set(jwk.AlgorithmKey, jwa.RS256())) + + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256(), privJWK)) + require.NoError(t, err) + return string(signed) +} + +// runMiddleware wires the middleware around a handler that records whether it +// was reached and returns the recorder + handler-reached flag pointer. +func runMiddleware(mw func(http.Handler) http.Handler, req *http.Request) (*httptest.ResponseRecorder, *bool, *http.Request) { + reached := false + var capturedReq *http.Request + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + capturedReq = r + w.WriteHeader(http.StatusOK) + })) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr, &reached, capturedReq +} + +func TestRequireJWT_ValidToken_PassesThroughWithClaims(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + "organization_id": "org_pickleball", + "organization_roles": []string{"platform_admin"}, + "scope": "read:tournaments write:tournaments", + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + + rr, reached, capturedReq := runMiddleware(mw, req) + + require.True(t, *reached, "downstream handler should be reached on valid token") + require.Equal(t, http.StatusOK, rr.Code) + + claims, ok := auth.ClaimsFromContext(capturedReq.Context()) + require.True(t, ok, "claims should be present in handler context") + require.Equal(t, "user_abc", claims.Subject) + require.Equal(t, "org_pickleball", claims.OrganizationID) + require.Equal(t, []string{"platform_admin"}, claims.OrganizationRoles) + require.ElementsMatch(t, []string{"read:tournaments", "write:tournaments"}, claims.Scopes) + require.Equal(t, []string{testOrgURNAud}, claims.Audience) +} + +// RFC 6750 §2.1: the auth scheme is case-insensitive. The middleware uses +// strings.EqualFold; this test guards against a future regression to +// strings.HasPrefix that would silently break case-insensitive callers. +func TestRequireJWT_ValidToken_LowercaseScheme_Accepted(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testAPIaud}, + }) + + for _, scheme := range []string{"Bearer", "bearer", "BEARER", "BeArEr"} { + t.Run(scheme, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", scheme+" "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.True(t, *reached, "valid token with %q scheme must reach handler", scheme) + require.Equal(t, http.StatusOK, rr.Code) + }) + } +} + +func TestRequireJWT_MissingHeader_Returns401(t *testing.T) { + _, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached, "handler must not be reached without auth header") + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "unauthorized") +} + +func TestRequireJWT_MalformedAuthHeader_Returns401(t *testing.T) { + _, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + cases := []struct { + name string + header string + }{ + {"bearer no token", "Bearer"}, + {"bearer empty token", "Bearer "}, + {"wrong scheme", "Basic xxx"}, + {"no scheme", "xxx"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", tc.header) + + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached) + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "unauthorized") + }) + } +} + +func TestRequireJWT_WrongIssuer_Returns401(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.IssuerKey: "https://attacker.example/", + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached) + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") +} + +func TestRequireJWT_WrongAudience_Returns401(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{"https://random.example/whatever"}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached) + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") +} + +func TestRequireJWT_BadSignature_Returns401(t *testing.T) { + // JWKS server publishes pub of key1 only. We sign the token with key2. + // Validator must reject because the signature won't verify against any + // key in the published set. Security-critical: a forged token signed by + // any RSA key MUST NOT be accepted. + _, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + priv2, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + token := mintToken(t, priv2, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached, "forged-signature token must not reach handler") + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") +} + +func TestRequireJWT_ExpiredToken_Returns401(t *testing.T) { + // Verifies the I-1 deferred check: jwt.WithValidate (default in v3 + // jwt.Parse) DOES reject tokens whose exp claim is in the past. The + // token is otherwise valid -- correct signer, issuer, and audience -- + // so the only reason for rejection is expiry. + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + expiredAt := time.Now().Add(-1 * time.Hour) + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + jwt.IssuedAtKey: time.Now().Add(-2 * time.Hour), + jwt.ExpirationKey: expiredAt, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached, "expired token must not reach handler -- I-1 verification") + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") + require.True(t, expiredAt.Before(time.Now()), "test pre-condition: exp must be in the past") +} + +func TestRequireJWT_OrgScopedFalse_RejectsOrgURN(t *testing.T) { + // orgScoped=false: the org-URN audience must be rejected even though + // the token is otherwise perfectly valid. + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, false) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached) + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") +} + +func TestRequireJWT_GlobalAudienceAccepted(t *testing.T) { + // Global API audience matches exactly: must pass regardless of the + // orgScoped flag's value. Run both to prove the policy. + priv, jwksURL := testKey(t) + + for _, orgScoped := range []bool{true, false} { + t.Run(fmt.Sprintf("orgScoped=%t", orgScoped), func(t *testing.T) { + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, orgScoped) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testAPIaud}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.True(t, *reached, "handler must be reached for global-aud token") + require.Equal(t, http.StatusOK, rr.Code) + }) + } +} + +// TestRequireJWT_JWKSUnavailable_Returns503 verifies that an infrastructure +// failure (Logto JWKS unreachable on cold start, no cached keys) surfaces +// as 503 service_unavailable, NOT 401 invalid_token. The old behavior +// confused operators chasing JWT bugs while the real cause was networking. +func TestRequireJWT_JWKSUnavailable_Returns503(t *testing.T) { + // Validator pointed at a TCP black hole. The httptest server is + // created and immediately closed so its port refuses connections. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + deadJWKSURL := srv.URL + srv.Close() + + v := auth.NewValidator(testIssuer, deadJWKSURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer some.token.here") + + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached, "handler must not be reached on JWKS failure") + require.Equal(t, http.StatusServiceUnavailable, rr.Code, + "JWKS-unreachable must surface as 503, not 401") + require.Contains(t, rr.Body.String(), "service_unavailable") +} + diff --git a/api/middleware/jwt_session.go b/api/middleware/jwt_session.go new file mode 100644 index 0000000..93f9a95 --- /dev/null +++ b/api/middleware/jwt_session.go @@ -0,0 +1,194 @@ +// api/middleware/jwt_session.go +// +// JWTSession is the Phase 3.5 bridge middleware. It validates the +// Logto JWT (must run AFTER RequireJWT), ensures a local users mirror +// row exists (same on-demand fetch+upsert pattern as MirrorUser), +// looks up the local row, and pushes a *session.Data into the request +// context. +// +// The 18 authenticated route groups in router.go that pre-Phase-3 +// used RequireAuth(SessionStore) read session.SessionData(r.Context()) +// in their handlers (149 call sites across handler/ and service/). +// JWTSession populates the same context value, so existing handlers +// don't need to change. Phase 6 cutover will swap reads to +// auth.ClaimsFromContext + a typed user lookup; until then the +// session.Data shape is the lingua franca. +// +// Mount order: +// +// r.Use(middleware.RequireJWT(validator, true)) +// r.Use(middleware.JWTSession(logtoClient, queries, userSync, orgRoles)) +// +// session.Data fields populated: +// - UserID: local users.id (int64) +// - Email: users.email (deref *string; "" if NULL in DB) +// - Role: users.role +// - PublicID: users.public_id +// - CreatedAt: users.created_at unix seconds +// +// session.Data.Impersonator* fields are NOT populated -- JWT has no +// impersonation concept yet. Phase 6 will need a Logto-native +// impersonation story or drop the feature. +package middleware + +import ( + "context" + "errors" + "log/slog" + "net/http" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/db/generated" + "github.com/court-command/court-command/service" + "github.com/court-command/court-command/session" + "github.com/jackc/pgx/v5" +) + +// JWTSessionQueries is the query surface JWTSession depends on. +// *generated.Queries satisfies it; tests stub it. +type JWTSessionQueries interface { + GetUserByLogtoUserID(ctx context.Context, logtoUserID *string) (generated.User, error) +} + +// JWTSession returns a middleware that bridges JWT-authenticated +// requests to the legacy session.Data context. Must be chained AFTER +// RequireJWT. +// +// If the local users mirror is missing, the middleware fetches the +// user from Logto's Management API and inserts it via UserSyncer +// before continuing -- same on-demand pattern as MirrorUser. After +// the upsert it re-reads the row to pick up the assigned local +// users.id. +// +// Role elevation has two paths, tried in order: +// +// 1. JWT fast path: claims.ElevatedRole() reads the JWT's +// organization_roles claim. This is the original design but Logto +// does NOT actually populate that claim in API-resource access +// tokens (it only ships in ID tokens and userinfo per Logto's +// design). Kept here so we benefit automatically if Logto ever +// adds the claim, or if a JWT customizer is configured to inject +// it. +// +// 2. Management API path: if the fast path returned nothing AND the +// token has an organization_id, ask the Logto Management API for +// the user's roles in that org via OrgRoleResolver. The resolver +// caches in Redis with a configurable TTL so the per-request +// overhead is dominated by cache hits. +// +// orgRoles can be nil -- in that case only the JWT fast path runs. +// Used by tests that don't want to wire a resolver fake, and by +// development environments without Logto Management API creds. +func JWTSession( + client LogtoUserFetcher, + queries JWTSessionQueries, + userSync UserSyncer, + orgRoles OrgRoleResolver, +) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + // RequireJWT didn't run, OR the route is misconfigured. + // Programmer error: surface as 500 so it's loud in dev. + writeError(w, http.StatusInternalServerError, "internal_error", "missing claims") + return + } + + sub := claims.Subject + user, err := queries.GetUserByLogtoUserID(r.Context(), &sub) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusInternalServerError, "internal_error", "user lookup failed") + return + } + // No local row: fetch from Logto and upsert. + lu, err := client.GetUser(r.Context(), sub) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "logto_unreachable", "cannot fetch user") + return + } + first, last := splitName(lu.Name) + if err := userSync.UpsertFromLogto(r.Context(), service.LogtoUserUpsert{ + LogtoUserID: lu.ID, + Email: lu.PrimaryEmail, + FirstName: first, + LastName: last, + DisplayName: lu.Name, + }); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "mirror failed") + return + } + // Re-read to get the assigned id. + user, err = queries.GetUserByLogtoUserID(r.Context(), &sub) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "post-mirror lookup failed") + return + } + } + + data := userToSessionData(&user) + + // Path 1: JWT fast path. Free if it works; no-op otherwise. + if elevated := claims.ElevatedRole(); elevated != "" && elevated != data.Role { + data.Role = elevated + } + + // Path 2: Management API path. Only when: + // - We didn't already elevate via the JWT fast path + // - The token carries an organization_id (otherwise we + // don't know which org's roles to look up) + // - The local DB role isn't already platform_admin + // (no point upgrading a user who's already at the top) + // - We have a resolver wired (production has one; some + // tests pass nil to skip this path) + if orgRoles != nil && + data.Role != "platform_admin" && + claims.OrganizationID != "" { + roles, lookupErr := orgRoles.GetUserOrganizationRoles( + r.Context(), claims.OrganizationID, sub) + if lookupErr != nil { + // Don't fail the request -- the user may legitimately + // be a non-admin and we shouldn't deny them just + // because Logto's Mgmt API hiccuped. Log so ops can + // see degraded elevation behavior. + slog.Warn("logto org-role lookup failed; falling back to local DB role", + "user_id", sub, + "org_id", claims.OrganizationID, + "error", lookupErr) + } else if containsRole(roles, "platform_admin") { + data.Role = "platform_admin" + } + // Other role names (tournament_director, etc.) are + // gated per-tournament via tournament_staff, NOT via + // users.role. Matches the spec from + // api/auth/context.go:ElevatedRole. + } + + ctx := session.SetSessionData(r.Context(), data) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// userToSessionData translates a Phase-2-plus generated.User row into +// the legacy session.Data shape that 149 handler call sites still +// read via session.SessionData(ctx). +func userToSessionData(u *generated.User) *session.Data { + d := &session.Data{ + UserID: u.ID, + Role: u.Role, + PublicID: u.PublicID, + } + if u.Email != nil { + d.Email = *u.Email + } + if !u.CreatedAt.IsZero() { + d.CreatedAt = u.CreatedAt.Unix() + } + return d +} + +// (Role-elevation logic moved to auth.Claims.ElevatedRole() so handlers +// like AuthHandler.MeJWT can apply the same mapping when returning the +// /api/v1/auth/me payload to the SPA.) diff --git a/api/middleware/jwt_session_test.go b/api/middleware/jwt_session_test.go new file mode 100644 index 0000000..d411e9a --- /dev/null +++ b/api/middleware/jwt_session_test.go @@ -0,0 +1,396 @@ +// api/middleware/jwt_session_test.go +// +// Unit tests for JWTSession. Reuses the fakeQueries/fakeFetcher/ +// fakeSyncer fakes from mirror_user_test.go (same package). These +// fakes already satisfy LogtoUserFetcher, JWTSessionQueries, and +// UserSyncer. +package middleware_test + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/db/generated" + "github.com/court-command/court-command/logto" + "github.com/court-command/court-command/middleware" + "github.com/court-command/court-command/session" +) + +// makeUser builds a canned generated.User for assertions. +func makeUser() generated.User { + email := "admin@courtcommand.local" + createdAt := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + return generated.User{ + ID: 42, + PublicID: "CC-10042", + Email: &email, + Role: "platform_admin", + CreatedAt: createdAt, + } +} + +// fakeQueriesWithSecondCall lets us simulate the post-mirror re-read +// returning a different row than the initial lookup. The first call +// returns ErrNoRows; the second returns the canned user. +type fakeQueriesWithSecondCall struct { + *fakeQueries + secondUser generated.User + calls int +} + +func (f *fakeQueriesWithSecondCall) GetUserByLogtoUserID(ctx context.Context, logtoUserID *string) (generated.User, error) { + f.calls++ + if f.calls == 1 { + return f.fakeQueries.GetUserByLogtoUserID(ctx, logtoUserID) + } + // Second call: return the post-mirror row. + return f.secondUser, nil +} + +// jwtSessionRunner wraps runMW so a downstream handler can capture the +// session.Data populated on the request context. +func jwtSessionRunner( + t *testing.T, + queries middleware.JWTSessionQueries, + fetcher middleware.LogtoUserFetcher, + syncer middleware.UserSyncer, + claims *auth.Claims, +) (*captured, bool, int) { + t.Helper() + // Pass nil for OrgRoleResolver in existing tests -- the Mgmt-API + // elevation path is exercised in TestJWTSession_OrgRoleResolver_*. + mw := middleware.JWTSession(fetcher, queries, syncer, nil) + cap := &captured{} + reached := false + var status int + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + cap.data = session.SessionData(r.Context()) + w.WriteHeader(http.StatusOK) + status = http.StatusOK + })) + rr, _ := runMWHelper(h, claims) + if !reached { + status = rr.Code + } + return cap, reached, status +} + +type captured struct { + data *session.Data +} + +// runMWHelper is a thin wrapper to hide the extra signature -- the +// existing runMW takes a func(http.Handler) http.Handler, but here +// we already have the wrapped handler. Re-implement the recorder. +func runMWHelper(h http.Handler, claims *auth.Claims) (*recorder, bool) { + rr := newRecorder() + req := newReq() + if claims != nil { + req = req.WithContext(auth.WithClaims(req.Context(), *claims)) + } + h.ServeHTTP(rr, req) + return rr, rr.Code != 0 +} + +// Re-export the testing helper types from runMW for our use; we +// can't import test files across packages, so we inline minimal copies. +type recorder struct { + Code int + Body []byte +} + +func newRecorder() *recorder { return &recorder{} } +func (r *recorder) Header() http.Header { + return http.Header{} +} +func (r *recorder) Write(b []byte) (int, error) { r.Body = append(r.Body, b...); return len(b), nil } +func (r *recorder) WriteHeader(s int) { r.Code = s } + +func newReq() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "/x", nil) + return req +} + +// --- Tests -------------------------------------------------------- + +// TestJWTSession_PopulatesSessionData_WhenRowExists: happy path. The +// queries fake returns the user immediately; no Logto fetch happens; +// downstream sees session.Data with the right fields. +func TestJWTSession_PopulatesSessionData_WhenRowExists(t *testing.T) { + user := makeUser() + queries := &fakeQueries{user: user, err: nil} + fetcher := &fakeFetcher{} // should NOT be called + syncer := &fakeSyncer{} + claims := &auth.Claims{Subject: "logto-user-id-abc"} + + cap, reached, _ := jwtSessionRunner(t, queries, fetcher, syncer, claims) + + require.True(t, reached, "downstream should be reached") + require.NotNil(t, cap.data, "session.Data should be set on context") + require.Equal(t, int64(42), cap.data.UserID) + require.Equal(t, "admin@courtcommand.local", cap.data.Email) + require.Equal(t, "platform_admin", cap.data.Role) + require.Equal(t, "CC-10042", cap.data.PublicID) + require.Equal(t, 0, len(syncer.calls), "no upsert should happen") + require.Equal(t, 0, fetcher.calls, "no Logto fetch should happen") +} + +// TestJWTSession_FetchesAndUpserts_WhenNoRow: cold cache. First +// queries call returns ErrNoRows; middleware fetches from Logto, +// upserts, re-reads. Downstream sees the post-mirror user. +func TestJWTSession_FetchesAndUpserts_WhenNoRow(t *testing.T) { + user := makeUser() + queries := &fakeQueriesWithSecondCall{ + fakeQueries: &fakeQueries{user: generated.User{}, err: pgx.ErrNoRows}, + secondUser: user, + } + fetcher := &fakeFetcher{ + user: &logto.LogtoUser{ + ID: "logto-user-id-abc", + PrimaryEmail: "admin@courtcommand.local", + Name: "Local Admin", + }, + } + syncer := &fakeSyncer{} + claims := &auth.Claims{Subject: "logto-user-id-abc"} + + cap, reached, _ := jwtSessionRunner(t, queries, fetcher, syncer, claims) + + require.True(t, reached) + require.Equal(t, int64(42), cap.data.UserID) + require.Equal(t, "platform_admin", cap.data.Role) + require.Equal(t, 1, fetcher.calls, "Logto should be fetched once") + require.Equal(t, 1, len(syncer.calls), "upsert should happen once") + require.Equal(t, 2, queries.calls, "GetUserByLogtoUserID should be called twice (lookup + post-upsert re-read)") +} + +// TestJWTSession_NoClaims_Returns500: programmer error path. If +// RequireJWT didn't run upstream, the bridge writes 500 INTERNAL_ERROR +// because reaching this state means the route is misconfigured. +func TestJWTSession_NoClaims_Returns500(t *testing.T) { + queries := &fakeQueries{} + fetcher := &fakeFetcher{} + syncer := &fakeSyncer{} + + cap, reached, status := jwtSessionRunner(t, queries, fetcher, syncer, nil) + + require.False(t, reached, "downstream must NOT be reached without claims") + require.Equal(t, http.StatusInternalServerError, status) + require.Nil(t, cap.data) +} + +// TestJWTSession_LogtoFails_Returns503: the Logto Mgmt API is down +// when we need to mirror a brand-new user. Middleware writes 503 and +// downstream is not reached. +func TestJWTSession_LogtoFails_Returns503(t *testing.T) { + queries := &fakeQueries{user: generated.User{}, err: pgx.ErrNoRows} + fetcher := &fakeFetcher{err: errors.New("logto unreachable")} + syncer := &fakeSyncer{} + claims := &auth.Claims{Subject: "logto-user-id-abc"} + + _, reached, status := jwtSessionRunner(t, queries, fetcher, syncer, claims) + + require.False(t, reached, "downstream must not be reached if Logto fetch fails") + require.Equal(t, http.StatusServiceUnavailable, status) +} + +// --- OrgRoleResolver elevation tests ------------------------------ +// +// These tests exercise the Path-2 (Mgmt API) elevation introduced to +// work around Logto's behavior of NOT emitting organization_roles on +// API-resource access tokens. + +// fakeOrgRoleResolver is an in-memory stub for the OrgRoleResolver +// interface. roles is keyed by orgID+":"+userID just like the real +// LogtoMgmtAPIResolver's internal cache. +type fakeOrgRoleResolver struct { + roles map[string][]string + err error + calls int +} + +func (f *fakeOrgRoleResolver) GetUserOrganizationRoles(_ context.Context, orgID, userID string) ([]string, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.roles[orgID+":"+userID], nil +} + +// jwtSessionWithResolver mirrors jwtSessionRunner but passes a +// resolver into the middleware constructor. +func jwtSessionWithResolver( + t *testing.T, + queries middleware.JWTSessionQueries, + fetcher middleware.LogtoUserFetcher, + syncer middleware.UserSyncer, + resolver middleware.OrgRoleResolver, + claims *auth.Claims, +) (*captured, bool) { + t.Helper() + mw := middleware.JWTSession(fetcher, queries, syncer, resolver) + cap := &captured{} + reached := false + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + cap.data = session.SessionData(r.Context()) + w.WriteHeader(http.StatusOK) + })) + runMWHelper(h, claims) + return cap, reached +} + +// TestJWTSession_OrgRoleResolver_ElevatesToPlatformAdmin: the local +// DB has the user as 'player' (default), the JWT has organization_id +// but no organization_roles claim, and the resolver reports +// platform_admin for that user/org. Expected: data.Role flips to +// platform_admin so downstream RequirePlatformAdmin lets the request +// through. +func TestJWTSession_OrgRoleResolver_ElevatesToPlatformAdmin(t *testing.T) { + u := makeUser() + u.Role = "player" // default-after-JIT-provision + queries := &fakeQueries{user: u} + fetcher := &fakeFetcher{} + syncer := &fakeSyncer{} + resolver := &fakeOrgRoleResolver{ + roles: map[string][]string{ + "vcx906e38a2v:logto-user-id-abc": {"platform_admin"}, + }, + } + claims := &auth.Claims{ + Subject: "logto-user-id-abc", + OrganizationID: "vcx906e38a2v", + // Note: NO OrganizationRoles -- this is Logto's actual behavior. + } + + cap, reached := jwtSessionWithResolver(t, queries, fetcher, syncer, resolver, claims) + + require.True(t, reached) + require.Equal(t, "platform_admin", cap.data.Role, "Mgmt API path should elevate") + require.Equal(t, 1, resolver.calls, "resolver should be called once") +} + +// TestJWTSession_OrgRoleResolver_NoElevationWhenUserHasNoOrgRoles: +// resolver returns empty slice (user is a member but has no roles). +// Expected: data.Role stays as the local DB value ('player'). +func TestJWTSession_OrgRoleResolver_NoElevationWhenUserHasNoOrgRoles(t *testing.T) { + u := makeUser() + u.Role = "player" + queries := &fakeQueries{user: u} + resolver := &fakeOrgRoleResolver{ + roles: map[string][]string{}, + } + claims := &auth.Claims{ + Subject: "logto-user-id-abc", + OrganizationID: "vcx906e38a2v", + } + + cap, reached := jwtSessionWithResolver(t, queries, &fakeFetcher{}, &fakeSyncer{}, resolver, claims) + + require.True(t, reached) + require.Equal(t, "player", cap.data.Role, "no roles -> no elevation") +} + +// TestJWTSession_OrgRoleResolver_SkippedWhenNoOrgInJWT: the JWT has no +// organization_id claim (e.g. resource-only token from the bare-root +// path). Expected: resolver is NOT called and role stays as DB. +func TestJWTSession_OrgRoleResolver_SkippedWhenNoOrgInJWT(t *testing.T) { + u := makeUser() + u.Role = "player" + queries := &fakeQueries{user: u} + resolver := &fakeOrgRoleResolver{ + // Even if the resolver WOULD say platform_admin, it shouldn't + // be called because we have no org context. + roles: map[string][]string{"any:logto-user-id-abc": {"platform_admin"}}, + } + claims := &auth.Claims{ + Subject: "logto-user-id-abc", + // OrganizationID is empty + } + + cap, reached := jwtSessionWithResolver(t, queries, &fakeFetcher{}, &fakeSyncer{}, resolver, claims) + + require.True(t, reached) + require.Equal(t, "player", cap.data.Role) + require.Equal(t, 0, resolver.calls, "resolver must not be called without org context") +} + +// TestJWTSession_OrgRoleResolver_SkippedWhenAlreadyPlatformAdmin: the +// local DB already has the user as platform_admin (perhaps from a +// manual admin promote). Expected: resolver is NOT called -- we don't +// downgrade or even re-check. +func TestJWTSession_OrgRoleResolver_SkippedWhenAlreadyPlatformAdmin(t *testing.T) { + u := makeUser() // role = "platform_admin" + queries := &fakeQueries{user: u} + resolver := &fakeOrgRoleResolver{ + // Resolver could say "no roles" but we shouldn't call it -- the + // DB authority wins for users who are already at the top. + roles: map[string][]string{}, + } + claims := &auth.Claims{ + Subject: "logto-user-id-abc", + OrganizationID: "vcx906e38a2v", + } + + cap, reached := jwtSessionWithResolver(t, queries, &fakeFetcher{}, &fakeSyncer{}, resolver, claims) + + require.True(t, reached) + require.Equal(t, "platform_admin", cap.data.Role) + require.Equal(t, 0, resolver.calls, "resolver must not be called for already-admin users") +} + +// TestJWTSession_OrgRoleResolver_ContinuesOnResolverError: Logto Mgmt +// API is down. We should NOT 503 -- just fall through to the local +// DB role. A logged warning is fine; the test asserts behavior, not +// log output. +func TestJWTSession_OrgRoleResolver_ContinuesOnResolverError(t *testing.T) { + u := makeUser() + u.Role = "player" + queries := &fakeQueries{user: u} + resolver := &fakeOrgRoleResolver{err: errors.New("logto mgmt api down")} + claims := &auth.Claims{ + Subject: "logto-user-id-abc", + OrganizationID: "vcx906e38a2v", + } + + cap, reached := jwtSessionWithResolver(t, queries, &fakeFetcher{}, &fakeSyncer{}, resolver, claims) + + require.True(t, reached, "request must not fail when resolver errors") + require.Equal(t, "player", cap.data.Role) +} + +// TestJWTSession_JWTFastPath_StillWorks: if Logto ever DOES start +// emitting organization_roles in access tokens (or a customizer is +// configured), the fast path should keep working without consulting +// the resolver. +func TestJWTSession_JWTFastPath_StillWorks(t *testing.T) { + u := makeUser() + u.Role = "player" + queries := &fakeQueries{user: u} + resolver := &fakeOrgRoleResolver{ + roles: map[string][]string{"vcx906e38a2v:logto-user-id-abc": {"platform_admin"}}, + } + claims := &auth.Claims{ + Subject: "logto-user-id-abc", + OrganizationID: "vcx906e38a2v", + OrganizationRoles: []string{"platform_admin"}, // JWT carried the claim + } + + cap, reached := jwtSessionWithResolver(t, queries, &fakeFetcher{}, &fakeSyncer{}, resolver, claims) + + require.True(t, reached) + require.Equal(t, "platform_admin", cap.data.Role, "JWT fast path should elevate") + require.Equal(t, 0, resolver.calls, "resolver must NOT be called when JWT already elevated") +} + +// --- helpers ------------------------------------------------------ + + diff --git a/api/middleware/mirror_user.go b/api/middleware/mirror_user.go new file mode 100644 index 0000000..d67cbda --- /dev/null +++ b/api/middleware/mirror_user.go @@ -0,0 +1,124 @@ +// api/middleware/mirror_user.go +// +// MirrorUser is the on-demand sync path that complements the +// LogtoWebhookHandler (eventual sync). When a JWT-authenticated +// request reaches a /me/* route and no local users row exists for +// the JWT subject, MirrorUser fetches the user from Logto's +// Management API and inserts the mirror row before continuing. +// +// This eliminates the gap between webhook delay and the very first +// authenticated request after sign-up: the user can sign up in Logto, +// be redirected straight to /pickleball/profile, and have a working +// profile-edit experience without waiting for the webhook to land. +// +// Must be chained AFTER RequireJWT so auth.ClaimsFromContext is +// populated. Without claims it short-circuits (next.ServeHTTP) so +// public routes that happen to share a router group aren't blocked. +// +// The dependencies are accepted as small interfaces (see below) +// rather than concrete types: this lets tests substitute hand-rolled +// fakes without spinning up Postgres or an httptest Logto server. +package middleware + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/db/generated" + "github.com/court-command/court-command/logto" + "github.com/court-command/court-command/service" + "github.com/jackc/pgx/v5" +) + +// LogtoUserFetcher is the slice of *logto.Client this middleware +// actually consumes. *logto.Client satisfies it; tests stub it with +// a struct returning canned responses. +type LogtoUserFetcher interface { + GetUser(ctx context.Context, userID string) (*logto.LogtoUser, error) +} + +// UserMirrorQueries is the slice of *generated.Queries this middleware +// uses. *generated.Queries satisfies it; tests stub it. +type UserMirrorQueries interface { + GetUserByLogtoUserID(ctx context.Context, logtoUserID *string) (generated.User, error) +} + +// UserSyncer is the slice of *service.UserSyncService this middleware +// calls when no local row exists yet. *service.UserSyncService +// satisfies it; tests stub it. +type UserSyncer interface { + UpsertFromLogto(ctx context.Context, in service.LogtoUserUpsert) error +} + +// MirrorUser ensures a local users row exists for the JWT subject on +// every request that flows through it. It MUST be chained AFTER +// RequireJWT. +// +// Behavior: +// - No claims on context => pass through (defensive; the +// enclosing group should always set claims, but we don't 500 if +// this middleware is mounted on a public route by accident) +// - Local row found => pass through +// - DB lookup error (non-NoRows) => 500 internal_error +// - No local row, fetch from Logto: +// - Logto error => 503 logto_unreachable +// - upsert error => 500 internal_error +// - upsert ok => pass through +func MirrorUser(client LogtoUserFetcher, queries UserMirrorQueries, userSync UserSyncer) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + next.ServeHTTP(w, r) + return + } + sub := claims.Subject + _, err := queries.GetUserByLogtoUserID(r.Context(), &sub) + if err == nil { + next.ServeHTTP(w, r) + return + } + if !errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusInternalServerError, "internal_error", "user lookup failed") + return + } + // Fetch from Logto and upsert. + lu, err := client.GetUser(r.Context(), claims.Subject) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "logto_unreachable", "cannot fetch user") + return + } + first, last := splitName(lu.Name) + if err := userSync.UpsertFromLogto(r.Context(), service.LogtoUserUpsert{ + LogtoUserID: lu.ID, + Email: lu.PrimaryEmail, + FirstName: first, + LastName: last, + DisplayName: lu.Name, + }); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "mirror failed") + return + } + next.ServeHTTP(w, r) + }) + } +} + +// splitName is duplicated from handler.splitName -- middleware can't +// import handler without a cycle, and these are tiny enough that +// extracting to a third package isn't worth it. Keep the two impls +// identical; if you change behavior here, update handler too. +func splitName(full string) (first, last string) { + parts := strings.SplitN(strings.TrimSpace(full), " ", 2) + if len(parts) == 0 || parts[0] == "" { + return "", "" + } + first = parts[0] + if len(parts) > 1 { + last = parts[1] + } + return +} diff --git a/api/middleware/mirror_user_test.go b/api/middleware/mirror_user_test.go new file mode 100644 index 0000000..370c12b --- /dev/null +++ b/api/middleware/mirror_user_test.go @@ -0,0 +1,175 @@ +// api/middleware/mirror_user_test.go +// +// Unit tests for MirrorUser. The middleware accepts small interfaces +// (LogtoUserFetcher, UserMirrorQueries, UserSyncer) so we substitute +// hand-rolled fakes -- no Postgres, no httptest Logto server. +package middleware_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/jackc/pgx/v5" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/db/generated" + "github.com/court-command/court-command/logto" + "github.com/court-command/court-command/middleware" + "github.com/court-command/court-command/service" +) + +// fakeQueries records GetUserByLogtoUserID calls and returns either a +// canned User+nil (row exists) or zero+pgx.ErrNoRows or zero+err. +type fakeQueries struct { + user generated.User + err error + // captured input + calledWith *string +} + +func (f *fakeQueries) GetUserByLogtoUserID(_ context.Context, logtoUserID *string) (generated.User, error) { + f.calledWith = logtoUserID + return f.user, f.err +} + +// fakeFetcher records GetUser calls and returns canned data. +type fakeFetcher struct { + user *logto.LogtoUser + err error + // captured + calls int +} + +func (f *fakeFetcher) GetUser(_ context.Context, _ string) (*logto.LogtoUser, error) { + f.calls++ + return f.user, f.err +} + +// fakeSyncer records UpsertFromLogto calls and returns canned err. +type fakeSyncer struct { + err error + calls []service.LogtoUserUpsert +} + +func (f *fakeSyncer) UpsertFromLogto(_ context.Context, in service.LogtoUserUpsert) error { + f.calls = append(f.calls, in) + return f.err +} + +// runMW invokes the middleware with the given fakes and returns +// (response recorder, downstreamReached). +func runMW(t *testing.T, mw func(http.Handler) http.Handler, claims *auth.Claims) (*httptest.ResponseRecorder, bool) { + t.Helper() + reached := false + h := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + if claims != nil { + req = req.WithContext(auth.WithClaims(req.Context(), *claims)) + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr, reached +} + +// TestMirrorUser_PassesThrough_WhenRowExists: the fake queries returns +// a user (no error). Logto fetcher must NOT be called, the downstream +// handler IS reached, no upsert. +func TestMirrorUser_PassesThrough_WhenRowExists(t *testing.T) { + q := &fakeQueries{user: generated.User{ID: 42}, err: nil} + f := &fakeFetcher{} + s := &fakeSyncer{} + mw := middleware.MirrorUser(f, q, s) + + claims := auth.Claims{Subject: "logto_existing"} + rr, reached := runMW(t, mw, &claims) + + if !reached { + t.Fatalf("downstream handler should be reached when row exists") + } + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + if f.calls != 0 { + t.Errorf("Logto fetcher must not be called when row exists, got %d calls", f.calls) + } + if len(s.calls) != 0 { + t.Errorf("upsert must not be called when row exists, got %d", len(s.calls)) + } + if q.calledWith == nil || *q.calledWith != "logto_existing" { + t.Errorf("queries called with wrong subject: %v", q.calledWith) + } +} + +// TestMirrorUser_FetchesAndUpserts_WhenNoRow: queries returns +// pgx.ErrNoRows, Logto returns a user, upsert is called, downstream +// handler is reached. +func TestMirrorUser_FetchesAndUpserts_WhenNoRow(t *testing.T) { + q := &fakeQueries{err: pgx.ErrNoRows} + f := &fakeFetcher{ + user: &logto.LogtoUser{ + ID: "logto_new", + PrimaryEmail: "new@example.com", + Name: "New User", + }, + } + s := &fakeSyncer{} + mw := middleware.MirrorUser(f, q, s) + + claims := auth.Claims{Subject: "logto_new"} + rr, reached := runMW(t, mw, &claims) + + if !reached { + t.Fatalf("downstream handler should be reached after successful upsert") + } + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + if f.calls != 1 { + t.Errorf("Logto fetcher should be called exactly once, got %d", f.calls) + } + if len(s.calls) != 1 { + t.Fatalf("upsert should be called exactly once, got %d", len(s.calls)) + } + got := s.calls[0] + if got.LogtoUserID != "logto_new" { + t.Errorf("upsert LogtoUserID: got %q want logto_new", got.LogtoUserID) + } + if got.Email != "new@example.com" { + t.Errorf("upsert Email: got %q", got.Email) + } + if got.FirstName != "New" || got.LastName != "User" { + t.Errorf("upsert name split wrong: first=%q last=%q", got.FirstName, got.LastName) + } + if got.DisplayName != "New User" { + t.Errorf("upsert DisplayName: got %q", got.DisplayName) + } +} + +// TestMirrorUser_LogtoFails_Returns503: queries says no row, Logto +// fetcher returns an error -- middleware writes 503 and downstream +// handler must NOT be reached. +func TestMirrorUser_LogtoFails_Returns503(t *testing.T) { + q := &fakeQueries{err: pgx.ErrNoRows} + f := &fakeFetcher{err: errors.New("connection refused")} + s := &fakeSyncer{} + mw := middleware.MirrorUser(f, q, s) + + claims := auth.Claims{Subject: "logto_unreachable"} + rr, reached := runMW(t, mw, &claims) + + if reached { + t.Fatalf("downstream handler must NOT be reached when Logto is unreachable") + } + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d body=%s", rr.Code, rr.Body.String()) + } + if len(s.calls) != 0 { + t.Errorf("upsert must not be called when Logto fetch fails") + } +} diff --git a/api/middleware/optional_jwt.go b/api/middleware/optional_jwt.go new file mode 100644 index 0000000..62f6822 --- /dev/null +++ b/api/middleware/optional_jwt.go @@ -0,0 +1,151 @@ +// api/middleware/optional_jwt.go +// +// OptionalJWT is the JWT counterpart to OptionalAuth. It validates the +// Authorization Bearer token if one is present and populates session.Data +// (via the same bridge logic as JWTSession), but never rejects an +// unauthenticated request. +// +// Use case: mixed-auth route groups where reads are public but writes +// (handler-level) check `session.SessionData(r.Context())` and 401 on +// nil. Pre-Phase-3 these routes worked because OptionalAuth populated +// session.Data from the cookie. After Phase 3 the SPA stopped sending +// cookies; without OptionalJWT the same handlers silently 401 every +// authenticated write. +// +// Mount globally alongside OptionalAuth -- the two cooperate: cookie +// path runs first, then JWT path. If either populates session.Data, +// the handler sees it. If neither does, the handler's nil check fires +// 401 (correct behavior). +// +// Differences from RequireJWT: +// - No Authorization header: pass through, no error. +// - Invalid token (signature, expiry, audience): pass through, no error. +// The route may be reachable anonymously; let the handler's auth +// check decide. +// - Logto Mgmt API unreachable on cold-cache lookup: pass through. +// We can't authenticate, but we shouldn't 503 a request that might +// have been anonymous anyway. +// +// Differences from JWTSession: +// - Permissive (passes through) on every error path. +// - Does NOT call writeError; never short-circuits the chain. + +package middleware + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/service" + "github.com/court-command/court-command/session" + "github.com/jackc/pgx/v5" +) + +// OptionalJWT returns a middleware that populates session.Data from a +// valid JWT if present. Pass-through on every failure path so anonymous +// requests survive. +// +// Args mirror JWTSession (validator + Logto client + queries + userSync) +// because the populate path is identical to the bridge. +func OptionalJWT( + validator *auth.Validator, + client LogtoUserFetcher, + queries JWTSessionQueries, + userSync UserSyncer, +) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Already populated by an earlier middleware? Skip; never + // override session data set by OptionalAuth. + if session.SessionData(r.Context()) != nil { + next.ServeHTTP(w, r) + return + } + + token, ok := bearerToken(r.Header.Get("Authorization")) + if !ok { + next.ServeHTTP(w, r) + return + } + + // orgScoped=true: the SPA sends org-scoped tokens; this matches + // the validator config used everywhere else in the app. + claims, err := validator.Validate(r.Context(), token, true) + if err != nil { + // JWKS unavailable is an infrastructure failure -- the + // auth provider is down. We still pass through (the + // permissive contract), but log at ERROR so operators + // see the signal during an outage. Without this branch, + // every authenticated mixed-auth write silently 401s + // (handler-level guard fires) and the only logs are + // debug-level "validate failed" messages typically + // filtered out at LevelInfo. + if errors.Is(err, auth.ErrJWKSUnavailable) { + slog.ErrorContext(r.Context(), + "optional-jwt jwks unavailable; mixed-auth writes will 401 until Logto recovers", + "err", err) + } else { + // Routine probes (expired, bad sig, wrong aud) + // log at debug -- they're spammy and not actionable. + slog.DebugContext(r.Context(), "optional-jwt validate failed", "err", err) + } + next.ServeHTTP(w, r) + return + } + + // Mirror the JWTSession lookup-then-fetch+upsert path, but + // permissive: any failure -> pass through unauthenticated + // rather than short-circuit. + sub := claims.Subject + user, err := queries.GetUserByLogtoUserID(r.Context(), &sub) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + slog.WarnContext(r.Context(), "optional-jwt user lookup failed", "err", err) + next.ServeHTTP(w, r) + return + } + // No local row: try to mirror on-demand. Failure is + // tolerable (next.ServeHTTP without session.Data; handler's + // nil check will 401 if the route is auth-required). + lu, err := client.GetUser(r.Context(), sub) + if err != nil { + slog.WarnContext(r.Context(), "optional-jwt logto fetch failed", "err", err) + next.ServeHTTP(w, r) + return + } + first, last := splitName(lu.Name) + if err := userSync.UpsertFromLogto(r.Context(), service.LogtoUserUpsert{ + LogtoUserID: lu.ID, + Email: lu.PrimaryEmail, + FirstName: first, + LastName: last, + DisplayName: lu.Name, + }); err != nil { + slog.WarnContext(r.Context(), "optional-jwt upsert failed", "err", err) + next.ServeHTTP(w, r) + return + } + user, err = queries.GetUserByLogtoUserID(r.Context(), &sub) + if err != nil { + slog.WarnContext(r.Context(), "optional-jwt post-upsert lookup failed", "err", err) + next.ServeHTTP(w, r) + return + } + } + + data := userToSessionData(&user) + // Phase 3.6 review C2 fix: derive role from JWT claims so admin + // privileges flow through. The bridge's userToSessionData uses + // the local users.role column which defaults to 'player' for + // freshly-mirrored users; we override here when claims show an + // elevated org role. + if elevated := claims.ElevatedRole(); elevated != "" && elevated != data.Role { + data.Role = elevated + } + ctx := session.SetSessionData(r.Context(), data) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/api/middleware/optional_jwt_test.go b/api/middleware/optional_jwt_test.go new file mode 100644 index 0000000..dd6ba13 --- /dev/null +++ b/api/middleware/optional_jwt_test.go @@ -0,0 +1,226 @@ +// api/middleware/optional_jwt_test.go +// +// OptionalJWT tests focus on the permissive paths -- the populate path +// is the same as JWTSession's and is covered by jwt_session_test.go. +// Here we verify that bad/missing tokens, broken Logto, and broken DBs +// all result in pass-through (no session.Data, no 4xx/5xx response). + +package middleware_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/middleware" + "github.com/court-command/court-command/session" + "github.com/jackc/pgx/v5" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/require" +) + +// optionalJWTRunner wires up the middleware and runs a single GET /x +// request through it. Returns whether the downstream handler was +// reached and the session.Data observed (nil if not populated). +func optionalJWTRunner( + t *testing.T, + v *auth.Validator, + queries middleware.JWTSessionQueries, + fetcher middleware.LogtoUserFetcher, + syncer middleware.UserSyncer, + authHeader string, +) (sess *session.Data, reached bool, status int) { + t.Helper() + mw := middleware.OptionalJWT(v, fetcher, queries, syncer) + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + sess = session.SessionData(r.Context()) + w.WriteHeader(http.StatusOK) + })) + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/x", nil) + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + h.ServeHTTP(rr, req) + return sess, reached, rr.Code +} + +// TestOptionalJWT_NoHeader_PassesThrough: anonymous request gets through +// with no session.Data; downstream handler runs normally. +func TestOptionalJWT_NoHeader_PassesThrough(t *testing.T) { + priv, jwksURL := testKey(t) + _ = priv + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + + sess, reached, status := optionalJWTRunner(t, v, + &fakeQueries{}, &fakeFetcher{}, &fakeSyncer{}, "") + + require.True(t, reached, "downstream must be reached on anonymous request") + require.Nil(t, sess, "session.Data must NOT be set without auth") + require.Equal(t, http.StatusOK, status) +} + +// TestOptionalJWT_ExpiredToken_PassesThrough: expired tokens result in +// pass-through (NOT 401). Treat token errors as "user might be anonymous" +// rather than rejecting -- mixed-auth routes are reachable without auth. +func TestOptionalJWT_ExpiredToken_PassesThrough(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + + // Mint a token whose exp is in the past. + signed := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "logto-user-id-abc", + jwt.AudienceKey: []string{testAPIaud}, + jwt.IssuedAtKey: time.Now().Add(-1 * time.Hour), + jwt.ExpirationKey: time.Now().Add(-30 * time.Minute), + }) + + sess, reached, status := optionalJWTRunner(t, v, + &fakeQueries{}, &fakeFetcher{}, &fakeSyncer{}, "Bearer "+signed) + + require.True(t, reached, "downstream must be reached even on expired token") + require.Nil(t, sess, "session.Data must NOT be set when token rejected") + require.Equal(t, http.StatusOK, status, "OptionalJWT must NOT short-circuit on expired tokens") +} + +// TestOptionalJWT_ValidToken_PopulatesSession: happy path. With a valid +// token + existing local mirror row, OptionalJWT populates session.Data +// just like JWTSession. +func TestOptionalJWT_ValidToken_PopulatesSession(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + signed := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "logto-user-id-abc", + jwt.AudienceKey: []string{testAPIaud}, + }) + + user := makeUser() + queries := &fakeQueries{user: user} + + sess, reached, status := optionalJWTRunner(t, v, + queries, &fakeFetcher{}, &fakeSyncer{}, "Bearer "+signed) + + require.True(t, reached) + require.Equal(t, http.StatusOK, status) + require.NotNil(t, sess, "valid token must populate session.Data") + require.Equal(t, int64(42), sess.UserID) + require.Equal(t, "platform_admin", sess.Role) +} + +// TestOptionalJWT_DBLookupError_PassesThrough: if the local DB query +// fails for reasons other than ErrNoRows (e.g. transient connection +// issue), OptionalJWT must NOT 500 a public read. Pass through and let +// the handler decide. +func TestOptionalJWT_DBLookupError_PassesThrough(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + signed := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "logto-user-id-abc", + jwt.AudienceKey: []string{testAPIaud}, + }) + + queries := &fakeQueries{err: errors.New("connection refused")} + + sess, reached, status := optionalJWTRunner(t, v, + queries, &fakeFetcher{}, &fakeSyncer{}, "Bearer "+signed) + + require.True(t, reached) + require.Nil(t, sess, "DB error must NOT populate session.Data") + require.Equal(t, http.StatusOK, status, "OptionalJWT must NOT short-circuit on DB errors") +} + +// TestOptionalJWT_LogtoUnreachable_PassesThrough: if the local mirror +// is missing AND Logto is unreachable, OptionalJWT must pass through +// (NOT 503). Public reads should survive Logto outages. +func TestOptionalJWT_LogtoUnreachable_PassesThrough(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + signed := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "logto-user-id-abc", + jwt.AudienceKey: []string{testAPIaud}, + }) + + queries := &fakeQueries{err: pgx.ErrNoRows} + fetcher := &fakeFetcher{err: errors.New("connection refused")} + + sess, reached, status := optionalJWTRunner(t, v, + queries, fetcher, &fakeSyncer{}, "Bearer "+signed) + + require.True(t, reached) + require.Nil(t, sess) + require.Equal(t, http.StatusOK, status, "OptionalJWT must NOT 503 on Logto outage") +} + +// TestOptionalJWT_PreExistingSession_PreservesIt: if OptionalAuth (or any +// other earlier middleware) already populated session.Data, OptionalJWT +// must NOT clobber it -- even if the request also carries a JWT. +func TestOptionalJWT_PreExistingSession_PreservesIt(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + signed := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "logto-user-id-abc", + jwt.AudienceKey: []string{testAPIaud}, + }) + + // Pre-populated session.Data from "OptionalAuth" simulation. + preData := &session.Data{UserID: 999, Role: "player", PublicID: "CC-99999"} + + mw := middleware.OptionalJWT(v, &fakeFetcher{}, + &fakeQueries{user: makeUser()}, &fakeSyncer{}) + var observed *session.Data + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + observed = session.SessionData(r.Context()) + w.WriteHeader(http.StatusOK) + })) + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+signed) + req = req.WithContext(session.SetSessionData(req.Context(), preData)) + h.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.NotNil(t, observed) + require.Equal(t, int64(999), observed.UserID, "pre-existing session must NOT be replaced by JWT") + require.Equal(t, "player", observed.Role) +} + +// TestOptionalJWT_ElevatedRoleFromClaims: when the token claims include +// 'platform_admin' in organization_roles, OptionalJWT overrides the +// local users.role for THIS request. Verifies the C2 fix from the +// Phase 3.5 review. +func TestOptionalJWT_ElevatedRoleFromClaims(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + signed := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "logto-user-id-abc", + jwt.AudienceKey: []string{testAPIaud}, + // organization_roles is not a top-level field; it lives under + // the org-scoped token shape. mintToken just sets it as a + // custom claim and ExtractClaims unpacks it. + "organization_roles": []string{"platform_admin", "tournament_director"}, + }) + + // Local row says role=player (the default for newly-mirrored users). + playerRole := "player" + playerEmail := "newbie@example.com" + user := makeUser() + user.Role = playerRole + user.Email = &playerEmail + + queries := &fakeQueries{user: user} + + sess, reached, status := optionalJWTRunner(t, v, + queries, &fakeFetcher{}, &fakeSyncer{}, "Bearer "+signed) + + require.True(t, reached) + require.Equal(t, http.StatusOK, status) + require.NotNil(t, sess) + require.Equal(t, "platform_admin", sess.Role, + "claims with platform_admin org-role must override local users.role") +} + + diff --git a/api/middleware/org_role_resolver.go b/api/middleware/org_role_resolver.go new file mode 100644 index 0000000..4ee76b0 --- /dev/null +++ b/api/middleware/org_role_resolver.go @@ -0,0 +1,184 @@ +// api/middleware/org_role_resolver.go +// +// OrgRoleResolver fills the gap between Logto's published behavior and +// what the api expects: +// +// - Logto issues access tokens whose `aud` is an API resource indicator +// and whose `organization_id` claim names a sport org, but it does +// NOT include the `organization_roles` claim in those tokens. The +// claim only appears in ID tokens and at the userinfo endpoint per +// Logto's design (docs.logto.io says so explicitly). +// - The api's claims.ElevatedRole() reads `organization_roles` from +// the JWT. With Logto's actual behavior, that slice is always empty +// on the resource-scoped tokens the SPA sends. Elevation never +// fires; platform_admin gates stay closed. +// +// This file adds the missing piece: an interface (Resolver) and a +// LogtoMgmtAPIResolver implementation that asks Logto's Management API +// for the user's roles in an org on demand, with Redis caching so we +// don't pay the round-trip on every authenticated request. JWTSession +// calls it in the elevation path; tests pass a fake. +// +// Cache key: `cc:org-roles:{userID}:{orgID}`, value: JSON-encoded slice +// of role names, TTL configurable via LOGTO_ORG_ROLES_CACHE_TTL_SECONDS +// (defaults to 60s). On Redis errors we fall through to the underlying +// Logto call -- correctness over performance during cache outages. +package middleware + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "strconv" + "time" + + "github.com/redis/go-redis/v9" +) + +// OrgRoleResolver is the elevation-lookup surface JWTSession uses to +// translate (userID, orgID) -> role names. Returns an empty slice (or +// nil) when the user has no roles in the org. Errors only when the +// underlying source genuinely failed; "no roles" is not an error. +type OrgRoleResolver interface { + GetUserOrganizationRoles(ctx context.Context, orgID, userID string) ([]string, error) +} + +// logtoOrgRoleLookup is the slim subset of *logto.Client that the +// production resolver uses. *logto.Client satisfies it; tests stub it. +type logtoOrgRoleLookup interface { + GetUserOrganizationRoles(ctx context.Context, orgID, userID string) ([]string, error) +} + +// LogtoMgmtAPIResolver is the production OrgRoleResolver. It tries +// Redis first, falls through to the Logto Management API, and caches +// successful results. Construct one per app instance (it's stateless +// beyond its injected dependencies). +type LogtoMgmtAPIResolver struct { + client logtoOrgRoleLookup + redis *redis.Client + ttl time.Duration +} + +// orgRoleCacheKey is exported so the seeder, tests, and ops scripts +// can target the same Redis keys (e.g. for manual invalidation). +// Format keeps userID first so a single SCAN cc:org-roles::* +// finds every cached org for one user (useful when a Logto webhook +// for that user fires and we need to invalidate). +func orgRoleCacheKey(userID, orgID string) string { + return fmt.Sprintf("cc:org-roles:%s:%s", userID, orgID) +} + +// DefaultOrgRolesCacheTTL is the TTL for cached org-role responses +// when LOGTO_ORG_ROLES_CACHE_TTL_SECONDS is unset or unparseable. +// 60s strikes a balance: role revocation in Logto becomes visible +// within a minute, while warm caches absorb the bulk of authenticated +// traffic without hitting Logto on every request. +const DefaultOrgRolesCacheTTL = 60 * time.Second + +// NewLogtoMgmtAPIResolver builds the production resolver. If +// redisClient is nil the resolver still works -- every call hits +// Logto directly. ttl <= 0 falls back to DefaultOrgRolesCacheTTL. +func NewLogtoMgmtAPIResolver(client logtoOrgRoleLookup, redisClient *redis.Client, ttl time.Duration) *LogtoMgmtAPIResolver { + if ttl <= 0 { + ttl = DefaultOrgRolesCacheTTL + } + return &LogtoMgmtAPIResolver{ + client: client, + redis: redisClient, + ttl: ttl, + } +} + +// OrgRolesCacheTTLFromEnv reads LOGTO_ORG_ROLES_CACHE_TTL_SECONDS +// and returns it as a time.Duration. Falls back to +// DefaultOrgRolesCacheTTL on missing / invalid values. Logs a warning +// on parse failure so misconfiguration isn't silent. +func OrgRolesCacheTTLFromEnv() time.Duration { + raw := os.Getenv("LOGTO_ORG_ROLES_CACHE_TTL_SECONDS") + if raw == "" { + return DefaultOrgRolesCacheTTL + } + secs, err := strconv.Atoi(raw) + if err != nil || secs <= 0 { + slog.Warn("invalid LOGTO_ORG_ROLES_CACHE_TTL_SECONDS; using default", + "raw", raw, "default_seconds", int(DefaultOrgRolesCacheTTL.Seconds())) + return DefaultOrgRolesCacheTTL + } + return time.Duration(secs) * time.Second +} + +// GetUserOrganizationRoles checks Redis first, then Logto. On Redis +// errors (network blip, key corruption) it falls through to Logto so +// the caller still gets a correct answer -- we never reject a request +// purely because the cache is down. On Logto errors it returns the +// error; the caller is expected to skip elevation and proceed with +// the local DB role. +func (r *LogtoMgmtAPIResolver) GetUserOrganizationRoles(ctx context.Context, orgID, userID string) ([]string, error) { + if r.redis != nil { + key := orgRoleCacheKey(userID, orgID) + raw, err := r.redis.Get(ctx, key).Bytes() + switch { + case err == nil: + // Cache hit. Empty JSON arrays decode to []string{}, which + // is the correct "no roles" answer; we keep negative + // results in the cache too so absent-from-org users + // don't hammer Logto on every request. + var roles []string + if jerr := json.Unmarshal(raw, &roles); jerr == nil { + return roles, nil + } + // Malformed cache entry: log and fall through to Logto. + slog.Warn("malformed org-roles cache entry; refetching", + "key", key, "error", "json unmarshal failed") + case errors.Is(err, redis.Nil): + // Cache miss -- normal, fall through to Logto. + default: + // Other Redis error -- log and fall through. Don't fail + // the request just because the cache hiccuped. + slog.Warn("redis read failed in org-roles resolver; falling through", + "key", key, "error", err) + } + } + + roles, err := r.client.GetUserOrganizationRoles(ctx, orgID, userID) + if err != nil { + return nil, err + } + if roles == nil { + // Normalize nil -> empty slice so cache entries are well-formed + // JSON and consumers can range over them safely. + roles = []string{} + } + + if r.redis != nil { + key := orgRoleCacheKey(userID, orgID) + payload, jerr := json.Marshal(roles) + if jerr == nil { + // SetNX is appropriate here -- if a parallel request just + // wrote the same value, no harm in skipping the duplicate + // write. But we WANT to refresh the TTL on hot keys, so + // use Set (with TTL) instead. + if rerr := r.redis.Set(ctx, key, payload, r.ttl).Err(); rerr != nil { + slog.Warn("redis write failed in org-roles resolver", + "key", key, "error", rerr) + } + } + } + + return roles, nil +} + +// containsRole returns true when roles contains target. Pulled out so +// JWTSession can use the same check the resolver uses internally, and +// so tests can verify the role-list shape independently. +func containsRole(roles []string, target string) bool { + for _, r := range roles { + if r == target { + return true + } + } + return false +} diff --git a/api/middleware/org_role_resolver_test.go b/api/middleware/org_role_resolver_test.go new file mode 100644 index 0000000..f70065f --- /dev/null +++ b/api/middleware/org_role_resolver_test.go @@ -0,0 +1,107 @@ +// api/middleware/org_role_resolver_test.go +// +// Tests for LogtoMgmtAPIResolver and the OrgRoleResolver interface. +// Avoid adding external dependencies for Redis: the resolver works +// without Redis (just calls Logto every time) and that's the path we +// exercise here. The end-to-end caching behavior is verified in +// production via the api boot logs and JWT inspection -- adding +// miniredis as a test dep just to cover one branch isn't worth it +// given we already have integration coverage at the Coolify layer. + +package middleware_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/court-command/court-command/middleware" +) + +// fakeLogtoOrgClient is the test stub for the resolver's underlying +// Logto Mgmt API client. Records the (orgID, userID) pairs it sees so +// tests can assert call counts. +type fakeLogtoOrgClient struct { + roles map[string][]string // key = orgID+":"+userID + err error + calls int +} + +func (f *fakeLogtoOrgClient) GetUserOrganizationRoles(_ context.Context, orgID, userID string) ([]string, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + if f.roles == nil { + return nil, nil + } + return f.roles[orgID+":"+userID], nil +} + +func TestLogtoMgmtAPIResolver_NoRedis_AlwaysCallsLogto(t *testing.T) { + t.Parallel() + // nil Redis -> resolver still works; every call pays the Logto round-trip. + fc := &fakeLogtoOrgClient{ + roles: map[string][]string{"o:u": {"platform_admin"}}, + } + r := middleware.NewLogtoMgmtAPIResolver(fc, nil, 60*time.Second) + + for i := 1; i <= 3; i++ { + got, err := r.GetUserOrganizationRoles(context.Background(), "o", "u") + require.NoError(t, err) + require.Equal(t, []string{"platform_admin"}, got) + require.Equal(t, i, fc.calls, "every call hits Logto without cache") + } +} + +func TestLogtoMgmtAPIResolver_NoRedis_PassesThroughNilRoles(t *testing.T) { + t.Parallel() + // User has no roles in this org: resolver returns empty slice + // (NOT nil) so consumers can range over the result safely. + fc := &fakeLogtoOrgClient{ + roles: map[string][]string{}, // no entry for "o:u" -> nil from underlying + } + r := middleware.NewLogtoMgmtAPIResolver(fc, nil, 60*time.Second) + + got, err := r.GetUserOrganizationRoles(context.Background(), "o", "u-without-roles") + require.NoError(t, err) + require.NotNil(t, got, "should normalize nil to empty slice") + require.Empty(t, got) +} + +func TestLogtoMgmtAPIResolver_NoRedis_LogtoError_Propagates(t *testing.T) { + t.Parallel() + fc := &fakeLogtoOrgClient{err: errors.New("logto unreachable")} + r := middleware.NewLogtoMgmtAPIResolver(fc, nil, 60*time.Second) + + _, err := r.GetUserOrganizationRoles(context.Background(), "o", "u") + require.Error(t, err) + require.Contains(t, err.Error(), "logto unreachable") +} + +func TestOrgRolesCacheTTLFromEnv_DefaultWhenUnset(t *testing.T) { + t.Setenv("LOGTO_ORG_ROLES_CACHE_TTL_SECONDS", "") + ttl := middleware.OrgRolesCacheTTLFromEnv() + require.Equal(t, middleware.DefaultOrgRolesCacheTTL, ttl) +} + +func TestOrgRolesCacheTTLFromEnv_ParsesValue(t *testing.T) { + t.Setenv("LOGTO_ORG_ROLES_CACHE_TTL_SECONDS", "120") + ttl := middleware.OrgRolesCacheTTLFromEnv() + require.Equal(t, 120*time.Second, ttl) +} + +func TestOrgRolesCacheTTLFromEnv_FallsBackOnGarbage(t *testing.T) { + t.Setenv("LOGTO_ORG_ROLES_CACHE_TTL_SECONDS", "not-a-number") + ttl := middleware.OrgRolesCacheTTLFromEnv() + require.Equal(t, middleware.DefaultOrgRolesCacheTTL, ttl) +} + +func TestOrgRolesCacheTTLFromEnv_FallsBackOnNegative(t *testing.T) { + t.Setenv("LOGTO_ORG_ROLES_CACHE_TTL_SECONDS", "-5") + ttl := middleware.OrgRolesCacheTTLFromEnv() + require.Equal(t, middleware.DefaultOrgRolesCacheTTL, ttl) +} diff --git a/api/middleware/sport_middleware.go b/api/middleware/sport_middleware.go new file mode 100644 index 0000000..85d905e --- /dev/null +++ b/api/middleware/sport_middleware.go @@ -0,0 +1,85 @@ +// api/middleware/sport_middleware.go +package middleware + +import ( + "log/slog" + "net/http" + + "github.com/court-command/court-command/auth" +) + +// SportResolver maps sport slugs (e.g. "pickleball") to Logto organization +// IDs (e.g. "ekup1zyrrxj4"). Populated at app startup from environment +// variables LOGTO_PICKLEBALL_ORG_ID and LOGTO_DEMO_SPORT_ORG_ID. The +// resolver is read-only after construction and safe for concurrent use. +type SportResolver struct { + slugToOrgID map[string]string +} + +// NewSportResolver builds a resolver from a slug-to-orgID map. The input +// map is copied; later mutation of the caller's map does not affect the +// resolver. +func NewSportResolver(slugToOrgID map[string]string) *SportResolver { + m := make(map[string]string, len(slugToOrgID)) + for k, v := range slugToOrgID { + m[k] = v + } + return &SportResolver{slugToOrgID: m} +} + +// OrgID returns the Logto organization ID for the given sport slug, or +// empty string if the slug is unknown. +func (s *SportResolver) OrgID(slug string) string { + return s.slugToOrgID[slug] +} + +// RequireSportMatchesJWT returns a chi middleware that requires the +// X-Sport header to be present, validates the slug exists in the resolver, +// and confirms the JWT's organization_id claim matches the sport's Logto +// org ID. Must be used AFTER RequireJWT so that auth.Claims is on the +// request context. +// +// Errors (in checking order): +// +// 400 - X-Sport header missing +// 400 - sport slug unknown to the resolver +// 500 - claims missing from context (programmer error: middleware not +// chained behind RequireJWT) +// 403 - claims.OrganizationID does not match the sport's Logto org ID +func RequireSportMatchesJWT(r *SportResolver) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + slug := req.Header.Get("X-Sport") + if slug == "" { + writeError(w, http.StatusBadRequest, "bad_request", "missing X-Sport header") + return + } + + expectedOrgID := r.OrgID(slug) + if expectedOrgID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "unknown sport: "+slug) + return + } + + claims, ok := auth.ClaimsFromContext(req.Context()) + if !ok { + // Programmer error: RequireSportMatchesJWT is running + // without RequireJWT in front of it. Fail closed and log + // loudly so it's caught in development. + slog.ErrorContext(req.Context(), + "sport middleware: no claims in context (RequireJWT not chained)") + writeError(w, http.StatusInternalServerError, "internal_error", + "server configuration error") + return + } + + if claims.OrganizationID != expectedOrgID { + writeError(w, http.StatusForbidden, "forbidden", + "sport does not match your current session's organization") + return + } + + next.ServeHTTP(w, req) + }) + } +} diff --git a/api/middleware/sport_middleware_test.go b/api/middleware/sport_middleware_test.go new file mode 100644 index 0000000..d336e90 --- /dev/null +++ b/api/middleware/sport_middleware_test.go @@ -0,0 +1,152 @@ +// api/middleware/sport_middleware_test.go +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/middleware" + "github.com/stretchr/testify/require" +) + +const ( + pickleballOrgID = "ekup1zyrrxj4" + demoSportOrgID = "7866ex96uk6b" +) + +func newTestResolver() *middleware.SportResolver { + return middleware.NewSportResolver(map[string]string{ + "pickleball": pickleballOrgID, + "demo_sport": demoSportOrgID, + }) +} + +// runSportMiddleware invokes mw against req and returns the recorder plus +// whether the downstream handler ran. ServeHTTP is synchronous so the +// returned bool is a stable read. +func runSportMiddleware(mw func(http.Handler) http.Handler, req *http.Request) (*httptest.ResponseRecorder, bool) { + rr := httptest.NewRecorder() + reached := false + h := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + })) + h.ServeHTTP(rr, req) + return rr, reached +} + +func TestSportResolver_OrgID(t *testing.T) { + src := map[string]string{ + "pickleball": pickleballOrgID, + "demo_sport": demoSportOrgID, + } + r := middleware.NewSportResolver(src) + + cases := []struct { + slug string + want string + }{ + {"pickleball", pickleballOrgID}, + {"demo_sport", demoSportOrgID}, + {"", ""}, + {"chess", ""}, + } + for _, tc := range cases { + t.Run("slug="+tc.slug, func(t *testing.T) { + require.Equal(t, tc.want, r.OrgID(tc.slug)) + }) + } + + // Mutating the caller's map after construction must not affect the + // resolver -- proves NewSportResolver copied the map. + src["pickleball"] = "tampered" + delete(src, "demo_sport") + require.Equal(t, pickleballOrgID, r.OrgID("pickleball"), + "resolver must be insulated from caller-side mutation") + require.Equal(t, demoSportOrgID, r.OrgID("demo_sport"), + "resolver must be insulated from caller-side deletion") +} + +func TestRequireSportMatchesJWT_Match_Passes(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Sport", "pickleball") + req = req.WithContext(auth.WithClaims(req.Context(), auth.Claims{ + Subject: "user_abc", + OrganizationID: pickleballOrgID, + })) + + rr, reached := runSportMiddleware(mw, req) + + require.True(t, reached, "handler should be reached when sport matches") + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestRequireSportMatchesJWT_HeaderMissing_Returns400(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req = req.WithContext(auth.WithClaims(req.Context(), auth.Claims{ + OrganizationID: pickleballOrgID, + })) + + rr, reached := runSportMiddleware(mw, req) + + require.False(t, reached, "handler must not be reached without X-Sport header") + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "missing X-Sport") +} + +func TestRequireSportMatchesJWT_UnknownSport_Returns400(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Sport", "chess") + req = req.WithContext(auth.WithClaims(req.Context(), auth.Claims{ + OrganizationID: pickleballOrgID, + })) + + rr, reached := runSportMiddleware(mw, req) + + require.False(t, reached) + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "unknown sport") + require.Contains(t, rr.Body.String(), "chess") +} + +func TestRequireSportMatchesJWT_MismatchedOrgID_Returns403(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + // Header says pickleball, JWT carries demo_sport's org ID. + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Sport", "pickleball") + req = req.WithContext(auth.WithClaims(req.Context(), auth.Claims{ + OrganizationID: demoSportOrgID, + })) + + rr, reached := runSportMiddleware(mw, req) + + require.False(t, reached) + require.Equal(t, http.StatusForbidden, rr.Code) + require.Contains(t, rr.Body.String(), "sport does not match") +} + +func TestRequireSportMatchesJWT_NoClaimsInContext_Returns500(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + // Programmer-error scenario: RequireJWT was not chained, so claims + // are absent from the request context. Middleware must fail closed. + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Sport", "pickleball") + + rr, reached := runSportMiddleware(mw, req) + + require.False(t, reached, "handler must not be reached without claims") + require.Equal(t, http.StatusInternalServerError, rr.Code) + require.Contains(t, rr.Body.String(), "internal_error") + require.Contains(t, rr.Body.String(), "server configuration error", + "message must reflect the actual condition (programmer error), not contradict the 500 status") +} diff --git a/api/router/router.go b/api/router/router.go index 372b525..daa41da 100644 --- a/api/router/router.go +++ b/api/router/router.go @@ -9,7 +9,10 @@ import ( "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/db/generated" "github.com/court-command/court-command/handler" + "github.com/court-command/court-command/logto" "github.com/court-command/court-command/middleware" "github.com/court-command/court-command/service" "github.com/court-command/court-command/session" @@ -83,6 +86,115 @@ type Config struct { // Phase 4C: WebSocket WSHandler chi.Router + + // Logto Phase 3: public sport directory + SportsHandler *handler.SportsHandler + + // Logto Phase 3: profile endpoints (JWT-protected, mounted under + // /api/v1/me/profile). Optional in the Config so tests that don't + // care about Logto wiring (almost all of them today) can leave it + // nil and the routes simply don't register. + ProfileHandler *handler.ProfileHandler + JWTValidator *auth.Validator + + // Logto Phase 3 Task 9: webhook + on-demand user mirror. + // + // LogtoWebhookHandler is mounted publicly at + // /api/v1/webhooks/logto -- no auth middleware in front; the + // HMAC signature on the Logto-Signature-Sha-256 header IS the + // auth. Leaving it nil disables the route entirely (used by + // testutil.TestServer where no Logto deps are wired). + // + // LogtoClient + UserSyncService + Queries together enable the + // MirrorUser middleware on /api/v1/me/* protected routes. All + // three must be non-nil for the middleware to be chained; + // otherwise the routes still work but skip the on-demand + // mirror (the webhook path will eventually populate the row, + // at which point requests start succeeding). + LogtoWebhookHandler *handler.LogtoWebhookHandler + LogtoClient *logto.Client + UserSyncService *service.UserSyncService + Queries *generated.Queries + + // OrgRoles supplies the org-role lookup the JWTSession middleware + // uses to elevate users to platform_admin when their JWT lacks + // the organization_roles claim (which is Logto's default -- + // see api/middleware/org_role_resolver.go for the full story). + // When nil (testutil / dev-without-Logto) the elevation falls + // back to the JWT fast path only. + OrgRoles middleware.OrgRoleResolver + + // SportResolver maps a sport slug (the X-Sport header) to its Logto + // organization ID. When set AND the JWT auth path is active, useAuth + // chains middleware.RequireSportMatchesJWT after JWT auth on every + // sport-scoped protected group, so a token minted for one sport's + // org cannot act on another sport's resources. Nil in + // testutil/cookie-only environments (no claims in context), where + // the sport check is skipped entirely. + SportResolver *middleware.SportResolver +} + +// authMiddlewares returns the middleware chain that should gate +// authenticated route groups. In production (cfg.JWTValidator != nil) +// this is RequireJWT + JWTSession -- validates the Logto JWT and +// populates session.Data via on-demand mirror lookup, so existing +// handlers that read session.SessionData(r.Context()) continue working +// without code changes. +// +// In testutil/cookie-only environments (cfg.JWTValidator == nil) this +// falls back to the legacy RequireAuth(SessionStore) cookie path so +// existing test fixtures keep working. +// +// Phase 6 cutover will drop the cookie branch entirely. +func authMiddlewares(cfg *Config) []func(http.Handler) http.Handler { + if cfg.JWTValidator != nil && cfg.LogtoClient != nil && cfg.UserSyncService != nil && cfg.Queries != nil { + return []func(http.Handler) http.Handler{ + middleware.RequireJWT(cfg.JWTValidator, true), + middleware.JWTSession(cfg.LogtoClient, cfg.Queries, cfg.UserSyncService, cfg.OrgRoles), + } + } + return []func(http.Handler) http.Handler{ + middleware.RequireAuth(cfg.SessionStore), + } +} + +// jwtAuthActive reports whether the production JWT auth path is wired +// (vs. the legacy cookie-only path used by testutil.TestServer). The +// sport-scoped check only makes sense on the JWT path, since it reads +// auth.Claims (the organization_id) off the request context, which only +// the JWT chain populates. +func jwtAuthActive(cfg *Config) bool { + return cfg.JWTValidator != nil && cfg.LogtoClient != nil && + cfg.UserSyncService != nil && cfg.Queries != nil +} + +// useAuthNoSport applies only the base auth chain (JWT validation + +// session bridge, or the legacy cookie path). Use for authenticated +// route groups that are NOT sport-scoped -- e.g. identity endpoints +// reached before a sport is selected, where the SPA sends no X-Sport +// header and a non-org token. +func useAuthNoSport(r chi.Router, cfg *Config) { + for _, mw := range authMiddlewares(cfg) { + r.Use(mw) + } +} + +// useAuth applies the base auth chain and, on the JWT path with a +// configured SportResolver, additionally chains RequireSportMatchesJWT +// so a token minted for one sport's Logto org cannot act on another +// sport's resources. Used for every sport-scoped protected group. +// +// The sport check is appended AFTER the base chain so auth.Claims is on +// the request context when it runs (RequireSportMatchesJWT depends on +// it). It is skipped when the JWT path is inactive (cookie-only tests, +// where there are no claims) or when no resolver is configured. +func useAuth(r chi.Router, cfg *Config) { + for _, mw := range authMiddlewares(cfg) { + r.Use(mw) + } + if cfg.SportResolver != nil && jwtAuthActive(cfg) { + r.Use(middleware.RequireSportMatchesJWT(cfg.SportResolver)) + } } // New creates a chi.Router with all middleware and routes mounted. @@ -99,53 +211,125 @@ func New(cfg *Config) chi.Router { r.Use(middleware.CORS(cfg.AllowedOrigins)) r.Use(middleware.MaxBodySize(1 << 20)) // 1 MB default limit r.Use(middleware.OptionalAuth(cfg.SessionStore)) // Populate session data when cookie present + // Phase 3.6 C1: OptionalJWT mirrors OptionalAuth for the JWT path. + // Mixed-auth route groups (those mounted without an explicit + // useAuth wrapper -- e.g. /leagues, /tournaments where reads are + // public and writes do handler-level `if sess == nil { 401 }`) + // rely on a global middleware to populate session.Data. Without + // this, the SPA's JWT can never reach those handlers. + if cfg.JWTValidator != nil && cfg.LogtoClient != nil && cfg.UserSyncService != nil && cfg.Queries != nil { + r.Use(middleware.OptionalJWT( + cfg.JWTValidator, cfg.LogtoClient, cfg.Queries, cfg.UserSyncService)) + } // API v1 routes r.Route("/api/v1", func(r chi.Router) { // Public routes (no auth required) r.Get("/health", cfg.HealthHandler.Check) - // Auth routes (public) + // Sport directory (public — sport picker fetches this before the + // user picks an org and gets a JWT, so no auth middleware here). + if cfg.SportsHandler != nil { + r.Get("/sports", cfg.SportsHandler.ListSports) + } + + // Logto Phase 3: profile endpoints. Mounted under RequireJWT so + // every request has a validated Logto access token in context + // before reaching the handler. orgScoped=true because Phase 3 + // frontends call these with org-scoped tokens (urn:logto:org:*). + // Task 9 will add MirrorUser middleware in this same group so + // the local users.id is on the request context too. + if cfg.ProfileHandler != nil && cfg.JWTValidator != nil { + r.Group(func(r chi.Router) { + r.Use(middleware.RequireJWT(cfg.JWTValidator, true)) + // Task 9: chain MirrorUser AFTER RequireJWT so the + // local users row is guaranteed to exist for the + // JWT subject before the handler runs. Conditional + // because testutil.TestServer doesn't wire the + // Logto deps; in that case we keep the legacy + // behavior (handler resolves users.id directly via + // ProfileService.LookupUserByLogtoSubject). + if cfg.LogtoClient != nil && cfg.UserSyncService != nil && cfg.Queries != nil { + r.Use(middleware.MirrorUser(cfg.LogtoClient, cfg.Queries, cfg.UserSyncService)) + } + // Phase 3 fix: /api/v1/auth/me is now JWT-authenticated. + // The legacy cookie-mounted /auth/me below is removed in + // the same commit; the SPA only authenticates via JWT. + r.Get("/auth/me", cfg.AuthHandler.MeJWT) + r.Get("/me/profile", cfg.ProfileHandler.GetMyProfile) + r.Patch("/me/profile", cfg.ProfileHandler.PatchMyProfile) + }) + } + + // Logto webhook (public -- HMAC signature IS the auth). + // Mounted under /api/v1 like every other API route so a + // future API gateway / reverse proxy that path-routes on + // /api/v1 catches this too. + if cfg.LogtoWebhookHandler != nil { + r.Post("/webhooks/logto", cfg.LogtoWebhookHandler.Handle) + } + + // Auth routes. /register, /login, /logout stay on the cookie + // path until Phase 6 cutover deletes them. /auth/me is mounted + // on the JWT-protected block above when the JWT validator is + // configured (production); when it's nil (testutil.TestServer + // for legacy cookie-only tests), we fall back to the + // cookie-session Me handler here so existing tests that exercise + // the login -> /me flow keep working. r.Route("/auth", func(r chi.Router) { r.Post("/register", cfg.AuthHandler.Register) r.Post("/login", cfg.AuthHandler.Login) r.Post("/logout", cfg.AuthHandler.Logout) - // Authenticated auth routes + // Authenticated /auth/* sub-routes. Use the same JWT/cookie + // auth chain as the rest of the app so the SPA's JWT + // reaches MyTournamentStaff (Phase 3 fix C6). + // + // /auth/me itself is mounted in the dedicated Phase 3 JWT + // block above when JWTValidator is configured. When it's + // nil (testutil mode), authMiddlewares falls back to the + // cookie path and we mount the legacy /me here too. r.Group(func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) - r.Get("/me", cfg.AuthHandler.Me) + // Identity endpoints: reached before a sport is selected, + // so the SPA may send no X-Sport header. Use the base auth + // chain WITHOUT the sport-scoped check. + useAuthNoSport(r, cfg) + if cfg.JWTValidator == nil { + // Legacy fallback for testutil/cookie-only environments. + // Phase 6 cutover deletes this branch entirely. + r.Get("/me", cfg.AuthHandler.Me) + } r.Get("/me/tournament-staff", cfg.AuthHandler.MyTournamentStaff) }) }) // Player routes (authenticated) r.Route("/players", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.PlayerHandler.Routes()) }) // Team routes (authenticated) r.Route("/teams", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.TeamHandler.Routes()) }) // Organization routes (authenticated) r.Route("/organizations", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.OrgHandler.Routes()) }) // Venue routes (authenticated) r.Route("/venues", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.VenueHandler.Routes()) }) // Court routes (authenticated — standalone/floating courts) r.Route("/courts", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.CourtHandler.Routes()) }) @@ -160,14 +344,14 @@ func New(cfg *Config) chi.Router { r.Mount("/", cfg.SeasonHandler.Routes()) }) r.Route("/{leagueID}/division-templates", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.DivTemplateHandler.Routes()) }) r.Route("/{leagueID}/announcements", func(r chi.Router) { r.Mount("/", cfg.AnnouncementHandler.LeagueAnnouncementRoutes()) }) r.Route("/{leagueID}/registrations", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.LeagueRegHandler.Routes()) }) }) @@ -217,7 +401,7 @@ func New(cfg *Config) chi.Router { // Scoring presets (mixed auth: public reads, handler-level auth on writes) r.Route("/scoring-presets", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.ScoringPresetHandler.Routes()) }) @@ -235,7 +419,7 @@ func New(cfg *Config) chi.Router { // Authenticated writes/reads. r.Group(func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.MatchHandler.Routes()) }) }) @@ -249,7 +433,7 @@ func New(cfg *Config) chi.Router { // Bracket generation (authenticated) r.Route("/divisions/{divisionID}/bracket", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.BracketHandler.Routes()) }) @@ -260,7 +444,7 @@ func New(cfg *Config) chi.Router { // Team-scoped matches r.Route("/teams/{teamID}/matches", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.MatchHandler.TeamRoutes()) }) @@ -274,7 +458,7 @@ func New(cfg *Config) chi.Router { // Authenticated writes/reads. r.Group(func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.MatchSeriesHandler.Routes()) }) }) @@ -286,7 +470,7 @@ func New(cfg *Config) chi.Router { // Quick matches (authenticated) r.Route("/quick-matches", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.QuickMatchHandler.Routes()) }) @@ -301,7 +485,7 @@ func New(cfg *Config) chi.Router { // Player dashboard (authenticated) r.Route("/dashboard", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.DashboardHandler.Routes()) }) @@ -329,7 +513,7 @@ func New(cfg *Config) chi.Router { // Authenticated control panel routes r.Group(func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Get("/court/{courtID}/config", cfg.OverlayHandler.GetConfig) r.Put("/court/{courtID}/config/theme", cfg.OverlayHandler.UpdateTheme) r.Put("/court/{courtID}/config/elements", cfg.OverlayHandler.UpdateElements) @@ -343,22 +527,25 @@ func New(cfg *Config) chi.Router { // Source Profile routes (authenticated) r.Route("/source-profiles", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.SourceProfileHandler.Routes()) }) // --- Phase 8 routes --- // Stop impersonation — must be OUTSIDE admin group because - // the impersonated session has the target user's role (not platform_admin) + // the impersonated session has the target user's role (not + // platform_admin). Use the base auth chain WITHOUT the sport + // check: escaping impersonation must always succeed regardless of + // which sport scope the request carries. r.Route("/admin/stop-impersonation", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuthNoSport(r, cfg) r.Post("/", cfg.AdminHandler.StopImpersonation) }) // Admin routes (authenticated + platform_admin only) r.Route("/admin", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Use(middleware.RequirePlatformAdmin) r.Mount("/", cfg.AdminHandler.Routes()) if cfg.AdHandler != nil { @@ -383,7 +570,7 @@ func New(cfg *Config) chi.Router { // Upload routes (authenticated) r.Route("/uploads", func(r chi.Router) { - r.Use(middleware.RequireAuth(cfg.SessionStore)) + useAuth(r, cfg) r.Mount("/", cfg.UploadHandler.Routes()) }) diff --git a/api/service/auth.go b/api/service/auth.go index 3234bf4..ee7988a 100644 --- a/api/service/auth.go +++ b/api/service/auth.go @@ -201,6 +201,26 @@ func (s *AuthService) GetCurrentUser(ctx context.Context, sessionData *session.D return userToResponse(&user), nil } +// GetCurrentUserByLogtoSubject retrieves the user mirror row by Logto +// user ID. Used by the JWT-protected /api/v1/auth/me endpoint introduced +// in Phase 3 (replaces the cookie-session GetCurrentUser path). +// +// Phase 3 Task 9's MirrorUser middleware should run BEFORE this is +// called, guaranteeing the row exists. If the middleware is skipped +// (testutil paths), pgx.ErrNoRows is propagated as NotFound so the +// handler can return 404. +func (s *AuthService) GetCurrentUserByLogtoSubject(ctx context.Context, logtoUserID string) (*UserResponse, error) { + sub := logtoUserID + user, err := s.queries.GetUserByLogtoUserID(ctx, &sub) + if errors.Is(err, pgx.ErrNoRows) { + return nil, NewNotFound("user mirror not found") + } + if err != nil { + return nil, fmt.Errorf("looking up user by logto subject: %w", err) + } + return userToResponse(&user), nil +} + // TournamentStaffAssignment is the response for a user's current tournament staff role. type TournamentStaffAssignment struct { TournamentID int64 `json:"tournament_id"` diff --git a/api/service/events.go b/api/service/events.go index 1203452..573ed90 100644 --- a/api/service/events.go +++ b/api/service/events.go @@ -50,6 +50,14 @@ const ( EventTypeMatchConfigured = "match_configured" EventTypeScoreOverride = "score_override" EventTypeForfeitDeclared = "forfeit_declared" + + // Officiating / verbal calls — referee-recorded rulings that annotate the + // timeline without mutating the score. Written via RecordEvent from the + // referee console's VerbalsPanel. + EventTypeLet = "let" + EventTypeReDo = "re_do" + EventTypeFault = "fault" + EventTypeLineCall = "line_call" ) // AllEventTypes lists every valid event_type value in canonical order. @@ -75,4 +83,8 @@ var AllEventTypes = []string{ EventTypeMatchConfigured, EventTypeScoreOverride, EventTypeForfeitDeclared, + EventTypeLet, + EventTypeReDo, + EventTypeFault, + EventTypeLineCall, } diff --git a/api/service/match_contract_test.go b/api/service/match_contract_test.go index eb4020f..8d4ffa5 100644 --- a/api/service/match_contract_test.go +++ b/api/service/match_contract_test.go @@ -67,6 +67,10 @@ var canonicalEventTypes = map[string]string{ "EventTypeSubstitution": "substitution", "EventTypeScoreOverride": "score_override", "EventTypeForfeitDeclared": "forfeit_declared", + "EventTypeLet": "let", + "EventTypeReDo": "re_do", + "EventTypeFault": "fault", + "EventTypeLineCall": "line_call", } // TestEventTypeConstants_LowercaseSnake guards CR-1: every EventType constant diff --git a/api/service/profile.go b/api/service/profile.go new file mode 100644 index 0000000..a8c2a92 --- /dev/null +++ b/api/service/profile.go @@ -0,0 +1,226 @@ +// api/service/profile.go +// +// ProfileService is the Phase 3 (Logto integration) service for the new +// player_profiles 1:1 table. It is separate from the legacy PlayerService +// which still reads/writes scalar columns on the users table for the +// cookie-session API. Phase 6 cutover will collapse the two; until then +// the new /api/v1/me/profile endpoint owned by ProfileHandler is the +// only caller of this service. +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/court-command/court-command/db/generated" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// ProfileService wraps the generated player_profiles queries and translates +// between the storage shape (pgtype.Date, pgtype.Float8, *string columns) +// and the wire DTO that ProfileHandler returns to the JWT-authenticated +// frontend. +type ProfileService struct { + queries *generated.Queries +} + +// NewProfileService builds a ProfileService bound to the given query set. +func NewProfileService(q *generated.Queries) *ProfileService { + return &ProfileService{queries: q} +} + +// PlayerProfileDTO is the wire shape used by GET/PATCH /api/v1/me/profile. +// +// Field naming follows the actual player_profiles columns. The legacy +// users-table profile uses overlapping names (city, country, etc) but +// is exposed via PlayerService and is not part of this DTO. Notable +// quirks the JSON contract pins down: +// +// - JSON avatar_url maps to a Go field named AvatarURL (idiomatic Go). +// The generated.PlayerProfile struct calls it AvatarUrl; profileToDTO +// bridges the two. +// - DateOfBirth is a *string in YYYY-MM-DD form so the form can round-trip +// dates without a Date type the frontend has to decode. +// - Latitude / Longitude are *float64 because pgtype.Float8 is awkward to +// consume in JSON; nil means "no geocoding done yet". +// - There is no street_address column and no emergency_contact_relation +// column on player_profiles. address_line_1 + address_line_2 replace +// "street" in the original Phase 3 plan. +type PlayerProfileDTO struct { + UserID int64 `json:"user_id"` + Phone *string `json:"phone,omitempty"` + DuprID *string `json:"dupr_id,omitempty"` + VairID *string `json:"vair_id,omitempty"` + PaddleBrand *string `json:"paddle_brand,omitempty"` + PaddleModel *string `json:"paddle_model,omitempty"` + Gender *string `json:"gender,omitempty"` + Handedness *string `json:"handedness,omitempty"` + DateOfBirth *string `json:"date_of_birth,omitempty"` // YYYY-MM-DD + Bio *string `json:"bio,omitempty"` + AddressLine1 *string `json:"address_line_1,omitempty"` + AddressLine2 *string `json:"address_line_2,omitempty"` + City *string `json:"city,omitempty"` + StateProvince *string `json:"state_province,omitempty"` + Country *string `json:"country,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + FormattedAddress *string `json:"formatted_address,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + EmergencyContactName *string `json:"emergency_contact_name,omitempty"` + EmergencyContactPhone *string `json:"emergency_contact_phone,omitempty"` + MedicalNotes *string `json:"medical_notes,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` // JSON: avatar_url; Go: AvatarURL + IsProfileHidden bool `json:"is_profile_hidden"` +} + +// Get loads the player_profiles row for the user. When no row exists yet +// it returns an empty DTO carrying just the UserID so the frontend can +// render the form in "create" mode without a separate 404 handler. +// Any non-ErrNoRows database failure is surfaced wrapped. +func (s *ProfileService) Get(ctx context.Context, userID int64) (PlayerProfileDTO, error) { + p, err := s.queries.GetPlayerProfileRow(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return PlayerProfileDTO{UserID: userID}, nil + } + return PlayerProfileDTO{}, fmt.Errorf("get profile: %w", err) + } + return profileToDTO(p), nil +} + +// Upsert applies the partial update. Every DTO field is optional; nil +// pointers leave the existing column unchanged via the COALESCE pattern +// in UpsertPlayerProfile. After the write Upsert re-reads through Get so +// the response reflects the merged final state (including pre-existing +// columns the caller didn't send). +// +// IsProfileHidden is intentionally a plain bool on the DTO, not *bool: +// the form has a single checkbox and always sends a definite value. If +// future callers ever need "leave is_profile_hidden alone" semantics +// switch the DTO field to *bool and translate to pgtype.Bool here. +func (s *ProfileService) Upsert(ctx context.Context, userID int64, in PlayerProfileDTO) (PlayerProfileDTO, error) { + params, err := dtoToUpsertParams(userID, in) + if err != nil { + return PlayerProfileDTO{}, fmt.Errorf("convert: %w", err) + } + if _, err := s.queries.UpsertPlayerProfile(ctx, params); err != nil { + return PlayerProfileDTO{}, fmt.Errorf("upsert profile: %w", err) + } + return s.Get(ctx, userID) +} + +// LookupUserByLogtoSubject resolves a JWT sub claim (Logto user ID) to +// the local users.id. Used by ProfileHandler before any profile op so +// Phase 3 routes can be called by anyone holding a valid token without +// the caller needing to know the local DB id. +// +// When Task 9's MirrorUser middleware lands it will pre-populate the +// local users.id on the request context, eliminating this lookup hop in +// protected routes. Until then a missing mirror surfaces here as a +// pgx.ErrNoRows-wrapped error and HandleServiceError maps that to 500. +// Once the webhook + middleware are in place we'll add a typed +// NotFoundError so the handler can distinguish "no mirror yet" from +// generic db failure. +func (s *ProfileService) LookupUserByLogtoSubject(ctx context.Context, logtoUserID string) (int64, error) { + sub := logtoUserID + user, err := s.queries.GetUserByLogtoUserID(ctx, &sub) + if err != nil { + return 0, fmt.Errorf("lookup user by logto subject: %w", err) + } + return user.ID, nil +} + +// profileToDTO maps the generated row to the wire DTO. Pointer columns +// pass through; pgtype.Date and pgtype.Float8 are unwrapped only when +// Valid so JSON omitempty actually skips them when the DB has no value. +func profileToDTO(p generated.PlayerProfile) PlayerProfileDTO { + dto := PlayerProfileDTO{ + UserID: p.UserID, + Phone: p.Phone, + DuprID: p.DuprID, + VairID: p.VairID, + PaddleBrand: p.PaddleBrand, + PaddleModel: p.PaddleModel, + Gender: p.Gender, + Handedness: p.Handedness, + Bio: p.Bio, + AddressLine1: p.AddressLine1, + AddressLine2: p.AddressLine2, + City: p.City, + StateProvince: p.StateProvince, + Country: p.Country, + PostalCode: p.PostalCode, + FormattedAddress: p.FormattedAddress, + EmergencyContactName: p.EmergencyContactName, + EmergencyContactPhone: p.EmergencyContactPhone, + MedicalNotes: p.MedicalNotes, + AvatarURL: p.AvatarUrl, + IsProfileHidden: p.IsProfileHidden, + } + if p.DateOfBirth.Valid { + s := p.DateOfBirth.Time.Format("2006-01-02") + dto.DateOfBirth = &s + } + if p.Latitude.Valid { + v := p.Latitude.Float64 + dto.Latitude = &v + } + if p.Longitude.Valid { + v := p.Longitude.Float64 + dto.Longitude = &v + } + return dto +} + +// dtoToUpsertParams converts the wire DTO back to the generated narg-shaped +// params struct. Pointers pass through unchanged; date / lat / lng are +// boxed into pgtype values only when the caller provided them (a nil DTO +// pointer becomes a zero pgtype with Valid=false, which the COALESCE in +// UpsertPlayerProfile reads as "leave column alone"). +// +// is_profile_hidden is always sent as Valid=true because the DTO carries +// a definite bool. That means PATCH requests *do* always overwrite the +// stored is_profile_hidden, which matches the form's behaviour of +// always sending a checkbox value. +func dtoToUpsertParams(userID int64, in PlayerProfileDTO) (generated.UpsertPlayerProfileParams, error) { + p := generated.UpsertPlayerProfileParams{ + UserID: userID, + Phone: in.Phone, + DuprID: in.DuprID, + VairID: in.VairID, + PaddleBrand: in.PaddleBrand, + PaddleModel: in.PaddleModel, + Gender: in.Gender, + Handedness: in.Handedness, + Bio: in.Bio, + AddressLine1: in.AddressLine1, + AddressLine2: in.AddressLine2, + City: in.City, + StateProvince: in.StateProvince, + Country: in.Country, + PostalCode: in.PostalCode, + FormattedAddress: in.FormattedAddress, + EmergencyContactName: in.EmergencyContactName, + EmergencyContactPhone: in.EmergencyContactPhone, + MedicalNotes: in.MedicalNotes, + AvatarUrl: in.AvatarURL, + IsProfileHidden: pgtype.Bool{Bool: in.IsProfileHidden, Valid: true}, + } + if in.DateOfBirth != nil { + t, err := time.Parse("2006-01-02", *in.DateOfBirth) + if err != nil { + return p, fmt.Errorf("date_of_birth %q: %w", *in.DateOfBirth, err) + } + p.DateOfBirth = pgtype.Date{Time: t, Valid: true} + } + if in.Latitude != nil { + p.Latitude = pgtype.Float8{Float64: *in.Latitude, Valid: true} + } + if in.Longitude != nil { + p.Longitude = pgtype.Float8{Float64: *in.Longitude, Valid: true} + } + return p, nil +} diff --git a/api/service/sports.go b/api/service/sports.go new file mode 100644 index 0000000..40fa166 --- /dev/null +++ b/api/service/sports.go @@ -0,0 +1,50 @@ +// api/service/sports.go +package service + +import ( + "context" + "fmt" + + "github.com/court-command/court-command/db/generated" +) + +// SportsService handles sport lookup business logic. Sports are a small, +// rarely-changing lookup table (one row per supported sport). The service +// just wraps the generated queries and translates rows to DTOs so the +// generated.Sport type doesn't leak into HTTP responses. +type SportsService struct { + queries *generated.Queries +} + +// NewSportsService creates a new SportsService. +func NewSportsService(q *generated.Queries) *SportsService { + return &SportsService{queries: q} +} + +// SportDTO is the public JSON representation of a sport row. logto_org_id +// is included because the frontend needs it to request an org-scoped +// access token before the user picks a sport. +type SportDTO struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + LogtoOrgID string `json:"logto_org_id"` +} + +// List returns all active sports ordered by sort_order, name. +func (s *SportsService) List(ctx context.Context) ([]SportDTO, error) { + rows, err := s.queries.ListSports(ctx) + if err != nil { + return nil, fmt.Errorf("list sports: %w", err) + } + out := make([]SportDTO, len(rows)) + for i, r := range rows { + out[i] = SportDTO{ + ID: r.ID, + Slug: r.Slug, + Name: r.Name, + LogtoOrgID: r.LogtoOrgID, + } + } + return out, nil +} diff --git a/api/service/user_sync.go b/api/service/user_sync.go new file mode 100644 index 0000000..54b3213 --- /dev/null +++ b/api/service/user_sync.go @@ -0,0 +1,107 @@ +// api/service/user_sync.go +// +// UserSyncService is the central upsert path that keeps the local +// users table in sync with Logto. Two callers fan in here: +// +// 1. handler.LogtoWebhookHandler -- eventual sync on User.Created / +// User.Data.Updated / User.Deleted webhook events from Logto. +// 2. middleware.MirrorUser -- immediate on-demand sync the very +// first time a JWT subject reaches a /me/* protected route +// (closes the gap between webhook delay and first request). +// +// Both paths converge on UpsertFromLogto so the insert/update logic +// lives in exactly one place. +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/court-command/court-command/db/generated" + "github.com/jackc/pgx/v5" +) + +// UserSyncService owns the local users-table mirror of Logto's user +// directory. All methods are idempotent. +type UserSyncService struct { + queries *generated.Queries +} + +// NewUserSyncService binds a service to the given sqlc Queries handle. +func NewUserSyncService(q *generated.Queries) *UserSyncService { + return &UserSyncService{queries: q} +} + +// LogtoUserUpsert is the input shape for UpsertFromLogto. Callers +// (webhook + middleware) construct this from either the Logto webhook +// payload or a Logto Management API GetUser response. +type LogtoUserUpsert struct { + LogtoUserID string + Email string + FirstName string + LastName string + DisplayName string +} + +// UpsertFromLogto inserts or updates the local users mirror for the +// given Logto identity. The function is idempotent: an existing row is +// updated in place; a missing row is inserted. Callers can invoke it +// repeatedly without worrying about duplicates. +// +// Lookup uses GetUserByLogtoUserID, which takes *string because of the +// nullable column type; we take a local of the LogtoUserID string and +// pass its address rather than &in.LogtoUserID to keep the parameter +// pointer unambiguously local to this call. +func (s *UserSyncService) UpsertFromLogto(ctx context.Context, in LogtoUserUpsert) error { + sub := in.LogtoUserID + existing, err := s.queries.GetUserByLogtoUserID(ctx, &sub) + if err == nil { + // Update path: the row exists, refresh email + display_name. + // We deliberately do NOT touch first_name / last_name here -- + // the user owns those after creation; if Logto pushes a name + // change we surface it via display_name only. + display := in.DisplayName + _, err := s.queries.UpdateUserFromLogto(ctx, generated.UpdateUserFromLogtoParams{ + ID: existing.ID, + Email: in.Email, + DisplayName: &display, + }) + if err != nil { + return fmt.Errorf("update user: %w", err) + } + return nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("lookup user: %w", err) + } + // Insert path: no local row yet, create one. The query supplies + // the password_hash sentinel, status='active', role='player', and + // date_of_birth='1900-01-01' (see api/db/queries/users.sql). + _, err = s.queries.CreateUserFromLogto(ctx, generated.CreateUserFromLogtoParams{ + LogtoUserID: in.LogtoUserID, + Email: in.Email, + FirstName: in.FirstName, + LastName: in.LastName, + }) + if err != nil { + return fmt.Errorf("create user: %w", err) + } + return nil +} + +// SoftDelete marks the local users row corresponding to the given +// Logto user ID as soft-deleted (sets deleted_at). The row is left in +// place so referential integrity from tournaments.created_by, etc. is +// preserved. Returns nil for a no-op delete (already soft-deleted or +// never mirrored). +// +// SoftDeleteUserByLogtoUserID takes a non-pointer string in the +// generated signature (the @logto_user_id::TEXT cast in the SQL forces +// non-null), so we pass logtoUserID directly. +func (s *UserSyncService) SoftDelete(ctx context.Context, logtoUserID string) error { + if err := s.queries.SoftDeleteUserByLogtoUserID(ctx, logtoUserID); err != nil { + return fmt.Errorf("soft delete: %w", err) + } + return nil +} diff --git a/api/session/store.go b/api/session/store.go index 34faf09..79d0f09 100644 --- a/api/session/store.go +++ b/api/session/store.go @@ -31,7 +31,14 @@ type Data struct { PublicID string `json:"public_id"` CreatedAt int64 `json:"created_at"` - // Impersonation fields — set when an admin is viewing as another user. + // Impersonation fields — LEGACY cookie-path only. Set by the deprecated + // AdminHandler.StartImpersonation cookie flow, which is no longer mounted + // (impersonation now runs via Logto OAuth 2.0 Token Exchange on the JWT + // path — the act claim carries the impersonator, not these fields). + // Retained only because the cookie session store + legacy cookie Me + // handler still reference them in the testutil-only path. + // TODO(phase-6): delete these fields together with the cookie session + // store, the legacy Me handler, and AdminHandler.StartImpersonation. ImpersonatorID int64 `json:"impersonator_id,omitempty"` ImpersonatorPublicID string `json:"impersonator_public_id,omitempty"` ImpersonatorToken string `json:"impersonator_token,omitempty"` diff --git a/api/startup/verify_sports.go b/api/startup/verify_sports.go new file mode 100644 index 0000000..085bec9 --- /dev/null +++ b/api/startup/verify_sports.go @@ -0,0 +1,194 @@ +// Package startup contains one-shot validation routines that run after +// dependencies are constructed but before the HTTP server begins +// accepting traffic. They exist to convert silent misconfiguration into +// loud, actionable boot failures. +package startup + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/court-command/court-command/logto" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PendingSeedPrefix is the prefix migration 00042 writes into +// sports.logto_org_id whenever it sees a row still holding the stale +// hardcoded values from migration 00041 (e.g. 'pending-seed:pickleball'). +// The per-slug suffix is required because of the UNIQUE constraint on +// the column. The api's auto-bootstrap (logtoseed.Run, called from +// api/main.go) replaces these with the real Logto org IDs on the next +// boot. +const PendingSeedPrefix = "pending-seed" + +// IsPendingSeedPlaceholder reports whether v is the placeholder value +// (or any 'pending-seed:*' variant) that means "this row hasn't been +// seeded against the real Logto tenant yet." +func IsPendingSeedPlaceholder(v string) bool { + return v == PendingSeedPrefix || strings.HasPrefix(v, PendingSeedPrefix+":") +} + +// orgLister is the narrow subset of *logto.Client that +// VerifySportsOrgIDs depends on. Defining it here keeps the verifier +// trivial to test with a fake -- callers in production pass the real +// *logto.Client, tests pass an in-memory implementation. +type orgLister interface { + ListOrganizations(ctx context.Context) ([]logto.Organization, error) +} + +// SportRow is the minimal projection of the sports table the verifier +// reads. Exported so callers (production: queries against pgxpool; +// tests: literal slices) can build it directly. +type SportRow struct { + Slug string + OrgID string +} + +// LoadActiveSportsFromDB reads (slug, logto_org_id) for every active +// sport from the application database. Pulled out of VerifySportsOrgIDs +// so the verifier itself has no concrete pgx dependency and can be +// unit-tested without a database. +func LoadActiveSportsFromDB(ctx context.Context, pool *pgxpool.Pool) ([]SportRow, error) { + rows, err := pool.Query(ctx, + `SELECT slug, logto_org_id FROM sports WHERE is_active = true ORDER BY sort_order`) + if err != nil { + return nil, fmt.Errorf("query sports: %w", err) + } + defer rows.Close() + + var sports []SportRow + for rows.Next() { + var s SportRow + if err := rows.Scan(&s.Slug, &s.OrgID); err != nil { + return nil, fmt.Errorf("scan sport row: %w", err) + } + sports = append(sports, s) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate sport rows: %w", err) + } + return sports, nil +} + +// VerifySportsOrgIDs ensures every active sport's logto_org_id resolves +// to a real organization on the configured Logto tenant. +// +// Why this exists: Logto generates random org IDs at creation time, so +// the IDs hardcoded in early migrations are stale on every fresh tenant. +// When the SPA requests an org-scoped token via +// getAccessToken(resource, sport.logto_org_id) and the org doesn't +// exist, Logto silently issues a resource-only token instead of +// failing. The api's claims.ElevatedRole() then sees no +// organization_roles claim and never elevates the user to +// platform_admin -- the admin sidebar link disappears, with no error +// surfaced anywhere. This verifier turns that silent downgrade into a +// loud boot-time failure. +// +// Behavior: +// - In production (cfg.IsProduction == true) any problem is returned +// as a non-nil error. main.go logs and exits. +// - In development each problem is logged at WARN level and the +// function returns nil so local stacks can come up partially +// configured. +// +// The function is a no-op when logtoClient is nil (the existing pattern +// in main.go for environments without Logto Management API creds). +// +// Production callers should use VerifySportsOrgIDsFromDB, which handles +// the database read and forwards to this function. This signature takes +// pre-loaded rows so unit tests can exercise the verification logic +// without a Postgres connection. +func VerifySportsOrgIDs( + ctx context.Context, + sports []SportRow, + logtoClient orgLister, + isProduction bool, +) error { + if logtoClient == nil { + // Matches main.go's existing behavior when Management API env + // vars aren't set: the verifier silently no-ops because + // without a Logto client there's nothing to verify against. + // main.go has already logged a warning at the construction + // site, so we don't double-log here. + return nil + } + if len(sports) == 0 { + // No active sports yet -- happens on a brand-new install before + // migrations have populated the table, or in tests. Nothing + // to verify; not an error. + return nil + } + + // One Logto API call covers every sport. ListOrganizations is + // paginated to 100 in the client today; if Court Command ever + // supports >100 sports this will need pagination, but the fail + // mode (a real org missing from the page) would be visible here. + orgs, err := logtoClient.ListOrganizations(ctx) + if err != nil { + return fmt.Errorf("verify sports org IDs: list logto orgs: %w", err) + } + known := make(map[string]struct{}, len(orgs)) + for _, o := range orgs { + known[o.ID] = struct{}{} + } + + var problems []string + for _, s := range sports { + switch { + case s.OrgID == "" || IsPendingSeedPlaceholder(s.OrgID): + problems = append(problems, fmt.Sprintf( + "sport %q has placeholder logto_org_id=%q -- the api auto-bootstrap (logtoseed.Run) should have replaced this; check earlier boot logs for a logto seed failure", + s.Slug, s.OrgID)) + default: + if _, ok := known[s.OrgID]; !ok { + problems = append(problems, fmt.Sprintf( + "sport %q references logto_org_id=%q which does not exist on the Logto tenant -- the org may have been recreated; the api auto-bootstrap should re-sync on the next clean boot, or update the row to match Logto Console", + s.Slug, s.OrgID)) + } + } + } + + if len(problems) == 0 { + slog.Info("verified sports.logto_org_id against Logto tenant", + "sports_checked", len(sports), + "logto_orgs_listed", len(orgs)) + return nil + } + + if isProduction { + // Concatenate all problems into one error so the operator sees + // every issue in a single boot-failure message rather than + // fix-one-find-another. + return errors.New("sports.logto_org_id verification failed:\n - " + + strings.Join(problems, "\n - ")) + } + + for _, p := range problems { + slog.Warn("sports.logto_org_id verification problem (dev mode -- not failing boot)", + "problem", p) + } + return nil +} + +// VerifySportsOrgIDsFromDB is the production entry point: loads active +// sports from the application database and runs VerifySportsOrgIDs +// against them. Returns nil immediately when logtoClient is nil so +// callers don't need to gate the call themselves. +func VerifySportsOrgIDsFromDB( + ctx context.Context, + pool *pgxpool.Pool, + logtoClient orgLister, + isProduction bool, +) error { + if logtoClient == nil { + return nil + } + sports, err := LoadActiveSportsFromDB(ctx, pool) + if err != nil { + return fmt.Errorf("verify sports org IDs: %w", err) + } + return VerifySportsOrgIDs(ctx, sports, logtoClient, isProduction) +} diff --git a/api/startup/verify_sports_test.go b/api/startup/verify_sports_test.go new file mode 100644 index 0000000..32d289b --- /dev/null +++ b/api/startup/verify_sports_test.go @@ -0,0 +1,204 @@ +package startup + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/court-command/court-command/logto" +) + +// fakeOrgLister is an in-memory orgLister for unit tests. orgs is the +// list ListOrganizations returns; err, when non-nil, is returned +// instead. +type fakeOrgLister struct { + orgs []logto.Organization + err error +} + +func (f *fakeOrgLister) ListOrganizations(_ context.Context) ([]logto.Organization, error) { + if f.err != nil { + return nil, f.err + } + return f.orgs, nil +} + +func TestVerifySportsOrgIDs_AllValid_PassesSilently(t *testing.T) { + t.Parallel() + sports := []SportRow{ + {Slug: "pickleball", OrgID: "vcx906e38a2v"}, + {Slug: "padel", OrgID: "abc123def456"}, + } + lister := &fakeOrgLister{orgs: []logto.Organization{ + {ID: "vcx906e38a2v", Name: "Pickleball"}, + {ID: "abc123def456", Name: "Padel"}, + {ID: "extra-org-id", Name: "Some Other Org"}, // unrelated orgs are fine + }} + + for _, isProd := range []bool{true, false} { + err := VerifySportsOrgIDs(context.Background(), sports, lister, isProd) + if err != nil { + t.Fatalf("isProduction=%v: expected nil, got %v", isProd, err) + } + } +} + +func TestVerifySportsOrgIDs_PendingSeedPlaceholder_FailsInProd_WarnsInDev(t *testing.T) { + t.Parallel() + sports := []SportRow{ + {Slug: "pickleball", OrgID: "pending-seed:pickleball"}, + } + lister := &fakeOrgLister{orgs: []logto.Organization{ + {ID: "vcx906e38a2v", Name: "Pickleball"}, + }} + + // production: must fail with an actionable message. + err := VerifySportsOrgIDs(context.Background(), sports, lister, true) + if err == nil { + t.Fatal("isProduction=true with pending-seed:pickleball: expected error, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "pickleball") { + t.Errorf("error should name the sport, got: %s", msg) + } + if !strings.Contains(msg, "pending-seed") { + t.Errorf("error should include the placeholder value, got: %s", msg) + } + if !strings.Contains(msg, "auto-bootstrap") { + t.Errorf("error should reference the auto-bootstrap, got: %s", msg) + } + + // development: must NOT fail. + if err := VerifySportsOrgIDs(context.Background(), sports, lister, false); err != nil { + t.Fatalf("isProduction=false with pending-seed: expected nil, got %v", err) + } +} + +func TestIsPendingSeedPlaceholder(t *testing.T) { + t.Parallel() + cases := []struct { + v string + want bool + }{ + {"pending-seed", true}, + {"pending-seed:pickleball", true}, + {"pending-seed:demo_sport", true}, + {"pending-seed:anything", true}, + {"vcx906e38a2v", false}, + {"", false}, // empty handled separately by the verifier + {"pending", false}, + {"pending-seedX", false}, // no colon -> not a placeholder + } + for _, c := range cases { + if got := IsPendingSeedPlaceholder(c.v); got != c.want { + t.Errorf("IsPendingSeedPlaceholder(%q) = %v, want %v", c.v, got, c.want) + } + } +} + +func TestVerifySportsOrgIDs_EmptyOrgID_TreatedAsPlaceholder(t *testing.T) { + t.Parallel() + sports := []SportRow{{Slug: "pickleball", OrgID: ""}} + lister := &fakeOrgLister{orgs: []logto.Organization{}} + + err := VerifySportsOrgIDs(context.Background(), sports, lister, true) + if err == nil { + t.Fatal("expected error for empty orgID in production") + } + if !strings.Contains(err.Error(), "placeholder") { + t.Errorf("empty orgID should be reported as a placeholder, got: %s", err.Error()) + } +} + +func TestVerifySportsOrgIDs_StaleID_NotInLogto_FailsInProd(t *testing.T) { + t.Parallel() + sports := []SportRow{ + {Slug: "pickleball", OrgID: "ekup1zyrrxj4"}, // the stale 00041 ID + } + lister := &fakeOrgLister{orgs: []logto.Organization{ + {ID: "vcx906e38a2v", Name: "Pickleball"}, // real ID is different + }} + + err := VerifySportsOrgIDs(context.Background(), sports, lister, true) + if err == nil { + t.Fatal("expected error for stale orgID in production") + } + msg := err.Error() + if !strings.Contains(msg, "ekup1zyrrxj4") { + t.Errorf("error should include the stale ID, got: %s", msg) + } + if !strings.Contains(msg, "does not exist") { + t.Errorf("error should explain the org doesn't exist, got: %s", msg) + } + + // development: warn, don't fail. + if err := VerifySportsOrgIDs(context.Background(), sports, lister, false); err != nil { + t.Fatalf("isProduction=false with stale ID: expected nil, got %v", err) + } +} + +func TestVerifySportsOrgIDs_MultipleProblems_AllReportedInOneError(t *testing.T) { + t.Parallel() + sports := []SportRow{ + {Slug: "pickleball", OrgID: "pending-seed:pickleball"}, + {Slug: "padel", OrgID: "stale-id-xyz"}, + {Slug: "tennis", OrgID: "real-org-id"}, // good + } + lister := &fakeOrgLister{orgs: []logto.Organization{ + {ID: "real-org-id", Name: "Tennis"}, + }} + + err := VerifySportsOrgIDs(context.Background(), sports, lister, true) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "pickleball") || !strings.Contains(msg, "padel") { + t.Errorf("both problem sports should be reported in one error, got: %s", msg) + } + if strings.Contains(msg, "tennis") { + t.Errorf("the good sport should not appear in the error, got: %s", msg) + } +} + +func TestVerifySportsOrgIDs_NilLogtoClient_NoOp(t *testing.T) { + t.Parallel() + // Even with bad data, nil client means we can't verify -- match + // main.go's existing dev-mode "Logto disabled" pattern. + sports := []SportRow{{Slug: "pickleball", OrgID: "pending-seed:pickleball"}} + if err := VerifySportsOrgIDs(context.Background(), sports, nil, true); err != nil { + t.Fatalf("nil client should be a no-op even in production, got %v", err) + } +} + +func TestVerifySportsOrgIDs_NoActiveSports_NoOp(t *testing.T) { + t.Parallel() + // Brand-new install before migrations / tests using an empty DB. + lister := &fakeOrgLister{orgs: []logto.Organization{ + {ID: "vcx906e38a2v"}, + }} + if err := VerifySportsOrgIDs(context.Background(), nil, lister, true); err != nil { + t.Fatalf("empty sports slice should be a no-op, got %v", err) + } +} + +func TestVerifySportsOrgIDs_LogtoAPIError_PropagatedAsError(t *testing.T) { + t.Parallel() + // If the Management API itself is unreachable, we can't + // distinguish a real config problem from a transient network + // blip -- surface it as an error so the operator decides. main.go + // wraps this with context. + sports := []SportRow{{Slug: "pickleball", OrgID: "vcx906e38a2v"}} + lister := &fakeOrgLister{err: errors.New("logto management API down")} + + for _, isProd := range []bool{true, false} { + err := VerifySportsOrgIDs(context.Background(), sports, lister, isProd) + if err == nil { + t.Fatalf("isProduction=%v: expected Logto API error to propagate", isProd) + } + if !strings.Contains(err.Error(), "logto management API down") { + t.Errorf("expected wrapped underlying error, got: %s", err.Error()) + } + } +} diff --git a/api/testutil/testserver.go b/api/testutil/testserver.go index 3d16c15..61de55a 100644 --- a/api/testutil/testserver.go +++ b/api/testutil/testserver.go @@ -173,13 +173,19 @@ func TestServer(t *testing.T, pool *pgxpool.Pool) *httptest.Server { activityLogService := service.NewActivityLogService(queries) apiKeyService := service.NewApiKeyService(queries) uploadService := service.NewUploadService(queries, t.TempDir()) - adminHandler := handler.NewAdminHandler(queries, activityLogService, apiKeyService, store, uploadService) + // nil logtoClient: testutil runs the cookie-only path with no Logto Mgmt + // API; the JWT impersonation endpoint 503s here, which tests don't exercise. + adminHandler := handler.NewAdminHandler(queries, activityLogService, apiKeyService, store, uploadService, nil) uploadHandler := handler.NewUploadHandler(uploadService) // CMS Settings settingsService := service.NewSettingsService(pool) settingsHandler := handler.NewSettingsHandler(settingsService) + // Logto Phase 3: public sport directory + sportsService := service.NewSportsService(queries) + sportsHandler := handler.NewSportsHandler(sportsService) + r := router.New(&router.Config{ DB: pool, SessionStore: store, @@ -237,6 +243,9 @@ func TestServer(t *testing.T, pool *pgxpool.Pool) *httptest.Server { // CMS Settings SettingsHandler: settingsHandler, + + // Logto Phase 3 + SportsHandler: sportsHandler, }) ts := httptest.NewServer(r) diff --git a/api/ws/handler.go b/api/ws/handler.go index 3db65e4..761b906 100644 --- a/api/ws/handler.go +++ b/api/ws/handler.go @@ -18,24 +18,51 @@ const ( pingPeriod = (pongWait * 9) / 10 ) -var upgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - // TODO: restrict to allowed origins in production - return true - }, -} - // Handler manages WebSocket connections for real-time updates. type Handler struct { - ps *pubsub.PubSub - logger *slog.Logger + ps *pubsub.PubSub + logger *slog.Logger + upgrader websocket.Upgrader } -// NewHandler creates a new WebSocket handler. -func NewHandler(ps *pubsub.PubSub, logger *slog.Logger) *Handler { - return &Handler{ps: ps, logger: logger} +// NewHandler creates a new WebSocket handler. allowedOrigins is the set of +// browser Origins permitted to open a WebSocket (the same list parsed from +// CORS_ALLOWED_ORIGINS, see api/config/config.go). The configured web +// origin MUST be present so OBS / browser-source overlay clients, which +// load the overlay page from that origin, can connect. Requests with an +// empty Origin header (non-browser clients such as native apps and the Go +// test client) are always allowed, since the Origin check only defends +// against cross-site WebSocket hijacking from a browser. +func NewHandler(ps *pubsub.PubSub, logger *slog.Logger, allowedOrigins []string) *Handler { + allowed := make(map[string]bool, len(allowedOrigins)) + for _, o := range allowedOrigins { + if o != "" { + allowed[o] = true + } + } + + return &Handler{ + ps: ps, + logger: logger, + upgrader: websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + // Non-browser clients (native apps, server-to-server, the + // Go websocket test client) send no Origin; nothing to + // hijack, so allow them. + if origin == "" { + return true + } + if allowed[origin] { + return true + } + logger.Warn("websocket origin rejected", "origin", origin, "remote", r.RemoteAddr) + return false + }, + }, + } } // Routes returns a chi.Router with all WebSocket endpoints mounted. @@ -115,7 +142,7 @@ func (h *Handler) HandleOverlay(w http.ResponseWriter, r *http.Request) { // handleSubscription upgrades the HTTP connection to WebSocket, // subscribes to the given Redis channel, and relays messages to the client. func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request, channel string) { - conn, err := upgrader.Upgrade(w, r, nil) + conn, err := h.upgrader.Upgrade(w, r, nil) if err != nil { h.logger.Error("websocket upgrade failed", "channel", channel, "error", err) return diff --git a/docker-compose.bootstrap.yaml b/docker-compose.bootstrap.yaml new file mode 100644 index 0000000..3f6bd19 --- /dev/null +++ b/docker-compose.bootstrap.yaml @@ -0,0 +1,87 @@ +# docker-compose.bootstrap.yaml +# +# One-shot Logto + database bootstrap for production. +# +# This compose file is meant to be invoked manually after the main +# stack (docker-compose.yaml) is deployed and the api/db services are +# healthy. It runs the logto-seed binary once -- which: +# - registers / patches the API resource and 12 scopes in Logto +# - registers / updates the SMTP email connector (when SMTP env vars +# are provided) so magic-link sign-in actually delivers emails +# - configures the sign-in experience (email + username identifiers, +# verify=true when SMTP is wired) +# - ensures the User-type role with all 12 API scopes exists and is +# granted to the bootstrap admin +# - registers the Logto webhook pointed at the api service +# - syncs sports.logto_org_id in the application database to match +# the Pickleball / Demo Sport organizations Logto issued +# - then exits 0 (a single execution; not a long-running service) +# +# Usage (Coolify host or any host that can reach the prod db internally): +# +# 1. Source the production env file: +# set -a && . ./.env.prod && set +a +# 2. Build + run (joins the existing prod stack's network): +# docker compose \ +# -f docker-compose.yaml \ +# -f docker-compose.bootstrap.yaml \ +# run --rm bootstrap +# 3. The container exits 0 on success. Re-running is idempotent. +# +# When invoked this way, Compose merges this file's `bootstrap` service +# on top of the main stack: the bootstrap container reaches `db:5432` +# and `redis:6379` over the same internal network the api uses, so you +# never need to expose the database to the public internet. +# +# Environment variables consumed (all required for production): +# LOGTO_ENDPOINT, LOGTO_MANAGEMENT_API_APP_ID, +# LOGTO_MANAGEMENT_API_APP_SECRET, LOGTO_MANAGEMENT_API_RESOURCE, +# LOGTO_API_RESOURCE, LOGTO_SPA_REDIRECT_URI, LOGTO_WEBHOOK_URL, +# LOGTO_BOOTSTRAP_EMAIL, LOGTO_BOOTSTRAP_PASSWORD, LOGTO_BOOTSTRAP_NAME, +# DATABASE_URL, +# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, +# EMAIL_FROM, EMAIL_FROM_NAME, +# APP_ENV (set to "production" to skip Demo Sport). + +services: + bootstrap: + build: + context: ./api + args: + COMMIT: ${SOURCE_COMMIT:-${COOLIFY_GIT_COMMIT_SHA:-bootstrap}} + image: court-command-api # reuse the same image the api service builds + entrypoint: ["./logto-seed"] + profiles: + - bootstrap # excluded from default `docker compose up` + depends_on: + db: + condition: service_healthy + environment: + # Logto Mgmt API + LOGTO_ENDPOINT: ${LOGTO_ENDPOINT} + LOGTO_MANAGEMENT_API_APP_ID: ${LOGTO_MANAGEMENT_API_APP_ID} + LOGTO_MANAGEMENT_API_APP_SECRET: ${LOGTO_MANAGEMENT_API_APP_SECRET} + LOGTO_MANAGEMENT_API_RESOURCE: ${LOGTO_MANAGEMENT_API_RESOURCE:-https://default.logto.app/api} + # API + SPA + webhook URLs + LOGTO_API_RESOURCE: ${LOGTO_API_RESOURCE} + LOGTO_SPA_REDIRECT_URI: ${LOGTO_SPA_REDIRECT_URI:-https://courtcommand.app/auth/callback} + LOGTO_WEBHOOK_URL: ${LOGTO_WEBHOOK_URL:-https://api.courtcommand.app/api/v1/webhooks/logto} + # Bootstrap admin + LOGTO_BOOTSTRAP_EMAIL: ${LOGTO_BOOTSTRAP_EMAIL:-daniel.f.velez@gmail.com} + LOGTO_BOOTSTRAP_PASSWORD: ${LOGTO_BOOTSTRAP_PASSWORD} + LOGTO_BOOTSTRAP_NAME: ${LOGTO_BOOTSTRAP_NAME:-Daniel Velez} + # Application database (for sports.logto_org_id sync) + DATABASE_URL: ${DATABASE_URL} + # Production launch mode: skip Demo Sport + APP_ENV: ${APP_ENV:-production} + # SMTP (optional but strongly recommended for production -- + # required for magic-link sign-in and email verification) + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT:-465} + SMTP_USER: ${SMTP_USER} + SMTP_PASS: ${SMTP_PASS} + EMAIL_FROM: ${EMAIL_FROM} + EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-Court Command} + # Sign-up email verification (true requires SMTP to be configured) + EMAIL_VERIFY_ON_SIGNUP: ${EMAIL_VERIFY_ON_SIGNUP:-true} + restart: "no" # one-shot, do NOT restart on exit diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..074f903 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,104 @@ +# docker-compose.dev.yml +# +# Local development stack: Postgres + Redis + Logto. +# +# This file is a STANDALONE local dev environment, not an override of +# docker-compose.yaml. It runs the three persistent services that the +# Court Command backend talks to, with host port bindings so the Go +# backend (run natively via `go run main.go` or `make dev`) and the Vite +# frontend (run via `pnpm dev`) can connect from the host. +# +# Usage: +# docker compose -f docker-compose.dev.yml up -d +# make logto-seed # provisions Logto (idempotent) +# make dev # runs the Go backend natively +# +# To shut down: +# docker compose -f docker-compose.dev.yml down +# +# To wipe state and start fresh: +# docker compose -f docker-compose.dev.yml down -v +# +# See docs/LOCAL_DEV.md for the full first-run walkthrough. + +name: court-command-dev + +services: + db: + image: postgres:17-alpine + container_name: cc_db_dev + environment: + POSTGRES_USER: courtcommand + POSTGRES_PASSWORD: courtcommand + POSTGRES_DB: courtcommand + # POSTGRES_MULTIPLE_DATABASES is parsed by the init script in + # scripts/postgres-init/ to create additional databases (here: + # the one Logto needs). + POSTGRES_MULTIPLE_DATABASES: logto + ports: + - "5432:5432" + volumes: + - pgdata_dev:/var/lib/postgresql/data + - ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U courtcommand"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: cc_redis_dev + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + logto: + image: svhd/logto:1.22.0 + container_name: cc_logto_dev + depends_on: + db: + condition: service_healthy + # `npm run cli db seed -- --swe` creates Logto's tables on first run + # (schema migration + seed of built-in roles, OIDC keys, default + # tenant). On subsequent runs it's a no-op. Without this, Logto + # crash-loops because its expected tables don't exist. + entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] + ports: + - "3001:3001" # OIDC core endpoint (issuer, JWKS, /oidc/token) + - "3002:3002" # Logto admin UI (operator browser) + environment: + TRUST_PROXY_HEADER: "1" + DB_URL: "postgres://courtcommand:courtcommand@db:5432/logto" + ENDPOINT: "http://localhost:3001" + ADMIN_ENDPOINT: "http://localhost:3002" + # Logto's default DATABASE_CONNECTION_TIMEOUT (5000ms) is too tight + # for a cold-start boot on a local docker stack: the slonik pool + # acquires its first connection before module loading is fully + # complete and times out. 30s is plenty of headroom and only + # affects connection-establishment, not query timeouts. + DATABASE_CONNECTION_TIMEOUT: "30000" + # host.docker.internal is the magic host that resolves to the host + # machine's IP from inside Linux Docker containers (with the + # extra_hosts mapping below). Lets webhooks delivered by Logto + # reach the natively-run Go backend at http://host.docker.internal:8080. + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + # Logto exposes the OIDC discovery doc at /oidc/.well-known/openid-configuration + # NOT /api/.well-known/openid-configuration (the latter returns 404). + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/oidc/.well-known/openid-configuration > /dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + restart: unless-stopped + +volumes: + pgdata_dev: diff --git a/docker-compose.yaml b/docker-compose.yaml index f996999..39945a9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,6 +12,9 @@ # For local dev, override with: docker compose -f docker-compose.yaml -f docker-compose.local.yaml up services: + # Application database (Court Command). Isolated from Logto's data + # so a compromise of the api can't touch identity tables, and either + # database can be restored independently. db: image: postgres:17-alpine environment: @@ -27,6 +30,27 @@ services: retries: 5 restart: unless-stopped + # Identity database (Logto). Separate Postgres process + separate + # volume + separate role/password from the application db. The api + # service has no credentials that reach this instance; Logto has + # no credentials that reach the application db. Different security + # blast radii, independent backup/restore lifecycle, independent + # version pinning if Logto ever needs to lag Postgres major upgrades. + db_logto: + image: postgres:17-alpine + environment: + POSTGRES_USER: ${LOGTO_DB_USER:-logto} + POSTGRES_PASSWORD: ${LOGTO_DB_PASSWORD:-logto} + POSTGRES_DB: ${LOGTO_DB_NAME:-logto} + volumes: + - pgdata_logto:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${LOGTO_DB_USER:-logto}"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + redis: image: redis:7-alpine healthcheck: @@ -36,23 +60,97 @@ services: retries: 5 restart: unless-stopped + # Logto self-hosted OIDC provider. Backed by the dedicated db_logto + # service. Two HTTP endpoints, each with its own public domain: + # :3001 -> OIDC core (issuer, JWKS, /oidc/token) -> logto.courtcommand.app + # :3002 -> Logto admin UI -> logto-admin.courtcommand.app + # + # Coolify's magic SERVICE_FQDN__ vars route traffic to the + # right container:port. Set the actual FQDN values in the Coolify env tab: + # SERVICE_FQDN_LOGTO_3001=https://logto.courtcommand.app + # SERVICE_FQDN_LOGTOADMIN_3002=https://logto-admin.courtcommand.app + logto: + image: svhd/logto:1.22.0 + depends_on: + db_logto: + condition: service_healthy + # `npm run cli db seed -- --swe` creates Logto's tables on first run + # (schema migration + seed of built-in roles, OIDC keys, default + # tenant). On subsequent runs it's a no-op. Without this, Logto + # crash-loops because its expected tables don't exist. + entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] + expose: + - "3001" + - "3002" + environment: + # Coolify proxy routing: tell the proxy to forward + # logto.courtcommand.app -> this container's :3001 + # logto-admin.courtcommand.app -> this container's :3002 + SERVICE_FQDN_LOGTO_3001: ${SERVICE_FQDN_LOGTO_3001:-https://logto.courtcommand.app} + SERVICE_FQDN_LOGTOADMIN_3002: ${SERVICE_FQDN_LOGTOADMIN_3002:-https://logto-admin.courtcommand.app} + TRUST_PROXY_HEADER: "1" + DB_URL: postgres://${LOGTO_DB_USER:-logto}:${LOGTO_DB_PASSWORD:-logto}@db_logto:5432/${LOGTO_DB_NAME:-logto} + ENDPOINT: ${LOGTO_ENDPOINT:-https://logto.courtcommand.app} + ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-https://logto-admin.courtcommand.app} + # Logto's default DATABASE_CONNECTION_TIMEOUT (5000ms) is too tight + # for cold-start boot. 30s is plenty of headroom and only affects + # connection-establishment, not query timeouts. + DATABASE_CONNECTION_TIMEOUT: "30000" + healthcheck: + # Logto exposes the OIDC discovery doc at /oidc/.well-known/openid-configuration + # NOT /api/.well-known/openid-configuration (the latter returns 404). + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/oidc/.well-known/openid-configuration > /dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + restart: unless-stopped + api: build: context: ./api dockerfile: Dockerfile + args: + # Coolify (this version) does not expose SOURCE_COMMIT or + # COOLIFY_GIT_COMMIT_SHA at docker build time; this arg arrives + # as the literal "unknown" and the Dockerfile uses it directly. + # The build_at timestamp in /api/v1/health remains a reliable + # deploy fingerprint. If a future Coolify version surfaces the + # SHA via either env var, swap this to ${COOLIFY_GIT_COMMIT_SHA:-unknown}. + COMMIT: unknown expose: - "8080" environment: + # Coolify proxy: api.courtcommand.app -> this container's :8080. + # The :- default fires only when the env var is unset; Coolify + # auto-creates SERVICE_FQDN_API_8080 as an empty string when the + # compose mentions it, so we have to set it in the Coolify env tab. + SERVICE_FQDN_API_8080: ${SERVICE_FQDN_API_8080:-https://api.courtcommand.app} DATABASE_URL: ${DATABASE_URL:-postgres://courtcommand:courtcommand@db:5432/courtcommand?sslmode=disable} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} PORT: "8080" APP_ENV: ${APP_ENV:-production} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-https://courtcommand.app,https://news.courtcommand.app} + # Logto OIDC (Phase 3+). Without these the API runs but every + # JWT-protected route 401s -- main.go fail-fast in production + # mode (APP_ENV=production) refuses to start when any are + # missing. See docs/LOGTO_SETUP.md for provisioning. + LOGTO_ENDPOINT: ${LOGTO_ENDPOINT:-} + LOGTO_API_RESOURCE: ${LOGTO_API_RESOURCE:-} + LOGTO_MANAGEMENT_API_APP_ID: ${LOGTO_MANAGEMENT_API_APP_ID:-} + LOGTO_MANAGEMENT_API_APP_SECRET: ${LOGTO_MANAGEMENT_API_APP_SECRET:-} + LOGTO_MANAGEMENT_API_RESOURCE: ${LOGTO_MANAGEMENT_API_RESOURCE:-} + LOGTO_WEBHOOK_SIGNING_KEY: ${LOGTO_WEBHOOK_SIGNING_KEY:-} depends_on: db: condition: service_healthy redis: condition: service_healthy + # api validates JWTs against logto's JWKS on startup (depending + # on the route mix). Wait for logto so the api's first health + # probe doesn't race a not-ready OIDC server. + logto: + condition: service_healthy volumes: - uploads:/app/uploads healthcheck: @@ -63,6 +161,70 @@ services: start_period: 10s restart: unless-stopped + # One-shot Logto bootstrap. + # + # Runs the logto-seed binary (baked into the api image). Idempotent: + # each step is find-or-create; safe to run multiple times. + # + # The seeder: + # - registers the API resource + 12 scopes in Logto + # - creates the SPA application (prints VITE_LOGTO_APP_ID) + # - configures the email connector + sign-in experience + # - creates the bootstrap admin (LOGTO_BOOTSTRAP_EMAIL) + # - registers the User-type role with all 12 API scopes + # - creates the Logto webhook (prints LOGTO_WEBHOOK_SIGNING_KEY) + # - syncs sports.logto_org_id in the application db + # - exits 0 + # + # Gated behind the `seed` profile so it does NOT run on every deploy. + # First-launch and re-seed flow: + # docker compose --profile seed up seed --abort-on-container-exit + # + # In Coolify (where compose profiles are awkward), invoke via the + # Terminal UI on the api container: + # ./logto-seed + # The binary is baked alongside court-command in the api image. + # + # After a fresh seed, copy the printed VITE_LOGTO_APP_ID + + # LOGTO_WEBHOOK_SIGNING_KEY into Coolify's env tab and redeploy so + # the web bundle bakes in VITE_LOGTO_APP_ID and the api receives + # LOGTO_WEBHOOK_SIGNING_KEY for HMAC verify. + seed: + profiles: ["seed"] + build: + context: ./api + dockerfile: Dockerfile + args: + COMMIT: unknown + entrypoint: ["./logto-seed"] + environment: + LOGTO_ENDPOINT: ${LOGTO_ENDPOINT:-} + LOGTO_API_RESOURCE: ${LOGTO_API_RESOURCE:-} + LOGTO_MANAGEMENT_API_APP_ID: ${LOGTO_MANAGEMENT_API_APP_ID:-} + LOGTO_MANAGEMENT_API_APP_SECRET: ${LOGTO_MANAGEMENT_API_APP_SECRET:-} + LOGTO_MANAGEMENT_API_RESOURCE: ${LOGTO_MANAGEMENT_API_RESOURCE:-https://default.logto.app/api} + LOGTO_SPA_REDIRECT_URI: ${LOGTO_SPA_REDIRECT_URI:-https://courtcommand.app/auth/callback} + LOGTO_WEBHOOK_URL: ${LOGTO_WEBHOOK_URL:-https://api.courtcommand.app/api/v1/webhooks/logto} + LOGTO_BOOTSTRAP_EMAIL: ${LOGTO_BOOTSTRAP_EMAIL:-daniel.f.velez@gmail.com} + LOGTO_BOOTSTRAP_PASSWORD: ${LOGTO_BOOTSTRAP_PASSWORD:-} + LOGTO_BOOTSTRAP_NAME: ${LOGTO_BOOTSTRAP_NAME:-Daniel Velez} + DATABASE_URL: ${DATABASE_URL:-postgres://courtcommand:courtcommand@db:5432/courtcommand?sslmode=disable} + APP_ENV: ${APP_ENV:-production} + SEED_DEMO_SPORT: ${SEED_DEMO_SPORT:-false} + EMAIL_VERIFY_ON_SIGNUP: ${EMAIL_VERIFY_ON_SIGNUP:-true} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-465} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + EMAIL_FROM: ${EMAIL_FROM:-} + EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-Court Command} + depends_on: + db: + condition: service_healthy + logto: + condition: service_healthy + restart: "no" + web: build: context: ./web @@ -70,8 +232,20 @@ services: args: VITE_API_URL: ${VITE_API_URL:-https://api.courtcommand.app} VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-} + # Logto OIDC -- these are baked into the JS bundle at build + # time. The frontend throws on init if VITE_LOGTO_ENDPOINT or + # VITE_LOGTO_APP_ID is missing. Set in Coolify env BEFORE the + # web service is rebuilt; changes require a build restart. + VITE_LOGTO_ENDPOINT: ${VITE_LOGTO_ENDPOINT:-} + VITE_LOGTO_APP_ID: ${VITE_LOGTO_APP_ID:-} + VITE_LOGTO_API_RESOURCE: ${VITE_LOGTO_API_RESOURCE:-} + VITE_AUTO_REDIRECT_SINGLE_SPORT: ${VITE_AUTO_REDIRECT_SINGLE_SPORT:-true} + VITE_WS_URL: ${VITE_WS_URL:-} expose: - "80" + environment: + # Coolify proxy: courtcommand.app -> this container's :80 + SERVICE_FQDN_WEB_80: ${SERVICE_FQDN_WEB_80:-https://courtcommand.app} depends_on: api: condition: service_healthy @@ -82,15 +256,35 @@ services: expose: - "2368" environment: + # Coolify proxy: news.courtcommand.app -> this container's :2368 + SERVICE_FQDN_GHOST_2368: ${SERVICE_FQDN_GHOST_2368:-https://news.courtcommand.app} url: ${GHOST_URL:-https://news.courtcommand.app} database__client: sqlite3 database__connection__filename: /var/lib/ghost/content/data/ghost.db NODE_ENV: production + # SMTP for Ghost member magic-link emails. Shares the same Resend + # account / verified domain as the Logto sign-in emails so both + # authenticate from the same sender reputation. Required for Ghost + # member sign-up to work; without it Ghost falls back to a console + # log and members never receive their login link. + # + # Ghost uses nodemailer's Direct/SMTP transport. We use generic + # SMTP (host/port/secure/auth.user/auth.pass) rather than the + # `service` preset so any SMTP-compatible provider works (Resend, + # Postmark, AWS SES, SendGrid, etc.) with the same env vars. + mail__transport: SMTP + mail__options__host: ${SMTP_HOST:-smtp.resend.com} + mail__options__port: ${SMTP_PORT:-465} + mail__options__secureConnection: "true" + mail__options__auth__user: ${SMTP_USER:-resend} + mail__options__auth__pass: ${SMTP_PASS:-} + mail__from: ${EMAIL_FROM_NAME:-Court Command} <${EMAIL_FROM:-noreply@mail.courtcommand.app}> volumes: - ghost_content:/var/lib/ghost/content restart: unless-stopped volumes: - pgdata: + pgdata: # courtcommand application data + pgdata_logto: # Logto identity data (separate volume = independent backup/restore) uploads: ghost_content: diff --git a/docs/BETA_PUNCHLIST.md b/docs/BETA_PUNCHLIST.md new file mode 100644 index 0000000..7c5307d --- /dev/null +++ b/docs/BETA_PUNCHLIST.md @@ -0,0 +1,161 @@ +# Court Command — Beta Punchlist & PR Plan + +> **Current status & handoff lives in [STATUS.md](STATUS.md)** — this file is the +> original PR plan/history. As of 2026-06-14, PRs #5–#19 are merged; see STATUS.md +> for what's done and what remains. + +Source of truth for wrapping up the beta. Derived from the annotated +[SMOKE_TEST.md](SMOKE_TEST.md) run (2026-06) plus the known-broken list in +[FEATURES.md](FEATURES.md) §21. + +**Base branch for all PRs:** `feature/logto-integration` +**Delivery:** one GitHub PR per work item against `RelentNet/court-command`. +**Merge policy:** master session auto-merges each PR once its checks are green +(go build + go test + tsc typecheck + eslint; live smoke where the stack is up), +then proceeds to dependents. + +Legend: ☐ not started · ◐ in progress (agent dispatched) · ☑ merged + +--- + +## Dependency waves + +``` +WAVE 1 (parallel — disjoint file sets) + PR-01 Auth & security hardening ── foundational, unblocks 08/09/11/17 + PR-02 Search crash fix + PR-03 Profile save UX + PR-04 Mutation feedback (team create + audit) + PR-05 Toast consistency + overlay save modal + PR-06 Dashboard polish + PR-07 TV tournament dark theme + PR-10 API keys listing + seed data + PR-12 Court stream embed + config UI + PR-13 Ref console redesign + PR-14 VAIR rating display (foundational for VAIR chain) + +WAVE 2 (after their blocker merges) + PR-08 Mobile responsive layout (after PR-01 — Sidebar) + PR-09 Auth/session FE edges (after PR-01 — auth FE) + PR-15 VAIR rating API sync (after PR-14; needs VAIR docs) + +WAVE 3 + PR-11 Navigation gaps + signed-in home (after PR-08 — Sidebar) + PR-17 Impersonation restore under JWT (after PR-09 — auth core) + PR-16 VAIR SSO + dashboard link (after PR-15) +``` + +--- + +## PRs + +### PR-01 — Auth & security hardening ☐ · effort: max · BLOCKER +Closes the pre-beta security gaps. Foundational; several PRs wait on it. +- Remove `TEMP-ADMIN-BYPASS` at all sites (`git grep TEMP-ADMIN-BYPASS`): + `api/middleware/auth.go` `RequirePlatformAdmin`, `web/src/features/admin/AdminGuard.tsx`, + `web/src/components/Sidebar.tsx`, `web/src/features/admin/bypass.ts`. +- Complete org-role → `users.role` mapping in `api/auth/context.go` so + tournament_director / referee / scorekeeper map through (today only + `platform_admin` elevates; everyone else lands as `player`). FEATURES §21. +- Chain `RequireSportMatchesJWT` on protected routes in `api/router/router.go` + (cross-sport data-leak window). FEATURES §21. +- Restrict WS `CheckOrigin` to `CORS_ALLOWED_ORIGINS` in `api/ws/handler.go:25`. +- Verify the bypass removal against the new Logto Mgmt-API elevation (commit + `8063475`) before deleting the gate. +- Smoke refs: 15.x admin gating. + +### PR-02 — Global search crash fix ☐ · effort: high +Search modal opens but typing a query blanks the entire page. Add debounce, +error handling, and a result-list fallback so a failed/empty search never +unmounts the app. Smoke 1.11–1.12. + +### PR-03 — Profile save UX ☐ · effort: high +- Invalidate/refetch the profile query after a save so values render without F5 + (phone, gender dropdown, all fields). Smoke 5.3, 5.7. +- Switch the profile Address block to the Google Places `AddressInput` used + elsewhere. Smoke 5.9. + +### PR-04 — Mutation feedback (team create + audit) ☐ · effort: high +Create Team submits, the form blanks, no confirmation. Redirect to the new +team (or show a success toast) and audit the other create flows +(org/venue/tournament) for the same missing feedback. Smoke 8.6. + +### PR-05 — Toast consistency + overlay save modal ☐ · effort: high +Overlay-settings save renders an inline banner that pushes the layout then +collapses (visible jank). Replace with the standard green popup toast and unify +toast styling/placement app-wide. Smoke 13.3, 16.4, 18.4. + +### PR-06 — Dashboard polish ☐ · effort: high +"Welcome back, Local" header is too small and too low-contrast. Resize / raise +contrast; light visual pass on the dashboard header. Smoke 2.5, 4.1. + +### PR-07 — TV tournament dark theme ☐ · effort: high +`/tv/tournaments/$id` renders on a light background. Default it to the Court +Command dark theme (sponsor/theming UI comes later). Smoke 14.2. + +### PR-08 — Mobile responsive layout ☐ · effort: high · after PR-01 +At <768px the sidebar should hide and the top bar + bottom tabs (Home / Events +/ Live / News / More) should appear; today the sidebar stays and tabs don't +show. Touches `Sidebar`/`AppShell` → sequence after PR-01. Smoke 16.3. + +### PR-09 — Auth/session FE edges ☐ · effort: high · after PR-01 +- Clean post-logout landing instead of dumping on the raw Logto + `/oidc/session/end` URL. Smoke 16.10. +- Second tab: on the next 401 after logout, redirect to a public page instead + of erroring on protected URLs. Smoke 17.4. +- `/auth/callback` with no query params: render a clean state, not a blank + page; confirm intended re-auth behavior. Smoke 17.6. + +### PR-10 — API keys listing + seed data ☐ · effort: high +- Only 1 of 2 seeded API keys lists. Fix the listing query / confirm seed + inserts both; keys without `logto_m2m_app_id` must still list. Smoke 15.9. +- Seed active quick matches so `/quick-match` isn't empty on a fresh seed. + Smoke 10.1. Files: `api/db/seed.sql`, `api/handler/api_key.go`, + `api/db/queries/api_keys.sql`. + +### PR-11 — Navigation gaps + signed-in home ☐ · effort: high · after PR-08 +Pages work by direct URL but have no nav path while signed in: `/public/live` +(1.9), `/public/events` (1.10), match scoreboard (9.3), `/tv/courts/$slug` +(14.1). Add nav entries. Also: let signed-in users reach the public/home +surface and make announcements from it (1.1, 3.1). Touches `Sidebar` → +sequence after PR-08. + +### PR-12 — Court stream embed + config UI ☐ · effort: high +Court detail marks a court live but renders no video embed when no `stream_url` +is set. Add a stream-config UI (URL + type) on court detail and render the +embed when present. Smoke 8.15. + +### PR-13 — Ref console redesign ☐ · effort: max +Ref console got stripped down to ~scorekeeper. Rebuild for refs: verbal calls +(let / re-do / fault / line call), full event log alongside scoring, and the +game-history bar. Fix the Space=point keyboard shortcut (S=side-out works). +Smoke 11.5, 11.6. FEATURES §21. + +### PR-14 — VAIR rating display ☐ · effort: high +Players list/detail/profile lead with DUPR; make VAIR the primary displayed +rating (platform stays rating-agnostic, VAIR preferred partner). Establishes +the VAIR data field the sync chain builds on. Smoke 8.1. + +### PR-15 — VAIR rating API sync ☐ · effort: max · after PR-14 · needs VAIR docs +Backend VAIR API client + sync job (pull/refresh ratings). Requires the VAIR +API documentation — drop it in `docs/vendor/vair/` before this dispatches. +Smoke 15.11. + +### PR-16 — VAIR SSO + dashboard link ☐ · effort: max · after PR-15 +SSO with VAIR and a jump-to-VAIR-dashboard link from the CC dashboard. +Coordinates with Logto. Smoke 15.11. + +### PR-17 — Impersonation restore under JWT ☐ · effort: max · after PR-09 +Restore admin impersonation via Logto OAuth 2.0 Token Exchange (RFC 8693) per +the 8-step plan in FEATURES §20: enable token exchange on the SPA app, backend +`/admin/users/:id/impersonate` + stop endpoints, FE token handling + +`ImpersonationBanner` on the `act` claim, retire the legacy cookie +`Impersonator*` fields. Smoke 15.11. + +--- + +## Deferred / not in this program +- Sponsor + theming UI for TV displays (beyond the dark-bg default in PR-07). +- Stripe billing / paid tiers. +- Second real sport beyond Pickleball (Demo Sport is a placeholder). +- Email / SMS notifications. diff --git a/docs/DATABASE_OWNERSHIP.md b/docs/DATABASE_OWNERSHIP.md new file mode 100644 index 0000000..5dbf05d --- /dev/null +++ b/docs/DATABASE_OWNERSHIP.md @@ -0,0 +1,175 @@ +# Database Ownership and Migration Rules + +This document codifies where Court Command stores data, who owns it, and when +a database migration is required. It exists because the Logto integration +(starting in v0.2.0) splits ownership across three zones, and "do I need a +migration?" is no longer a simple yes/no. + +**Audience:** anyone adding a new field, table, role, scope, or feature that +touches persisted state. + +--- + +## TL;DR + +- **Schema changes need a migration.** New tables, new columns, type/nullability + changes, new constraints, new indexes, dropped anything — all migrations. +- **Day-to-day data does not.** Inserting a tournament, registering a user, + updating a match score — normal API calls, no migration. +- **Identity data is owned by Logto, not the database.** Adding a Logto role, + scope, organization, or webhook is a Logto admin UI change, not a migration. + +--- + +## The three ownership zones + +### Zone A — Database is canonical + +The Postgres database in `api/db/migrations/` is the **single source of truth** +for everything in this zone. New fields here always require a goose migration. + +What lives here: + +- All Court Command **business entities**: + tournaments, divisions, pods, registrations, matches, match_events, + match_series, leagues, seasons, division_templates, league_registrations, + season_confirmations +- **Venues, courts**, court_queue, organizations *(the Court Command "club" + kind, not Logto orgs)*, venue_managers, tournament_courts +- **Player domain data**: phone, dupr_id, vair_id, paddle, gender, handedness, + date_of_birth, address, emergency contact, waiver, avatar — stored in the + `player_profiles` table (added in Phase 2) +- **Operational records**: standings, scoring_presets, source_profiles, + overlay configs, announcements, ad_configs, uploads, activity_logs, + site_settings +- **Mirror rows of Logto identities**: + - `users.id` (BIGSERIAL — Court Command's surrogate key) and + `users.logto_user_id` (the FK to Logto's user) + - `users.email` and `users.display_name` (cached for joinable display) + - `api_keys` rows that mirror Logto M2M apps via `logto_m2m_app_id` +- **The `sports` lookup table** (Pickleball, Demo Sport — added in Phase 2) + and all `sport_id` foreign keys on tournaments / leagues / organizations / + venues / divisions +- **Per-entity authorization tables** that Court Command, not Logto, models: + `org_memberships`, `tournament_staff`, `venue_managers`, + `league_memberships` (future) + +For everything in Zone A, the rule is unchanged from before the Logto +migration: **new field → goose migration → sqlc regen → Go service/handler +update → TypeScript type update**. + +### Zone B — Logto is canonical + +The self-hosted Logto instance at `https://logto.courtcommand.app` is the +**single source of truth** for identity and access-control vocabulary. The +Court Command database does **not** store any of the following authoritatively +— at most it caches them via webhooks. Adding or changing anything here is a +**Logto admin UI change** (or a Management API call), **not** a database +migration. + +What lives here: + +- `logto_user_id` (the canonical user identity that everything in Zone A + references) +- Email + password (Logto stores the bcrypt hash; the database caches the + email string for display) +- Display name (Logto authoritative; cached in `users.display_name`) +- `email_verified`, suspension state, MFA enrolment +- **Logto organizations** — `Pickleball`, `Demo Sport` (one Logto org per + Court Command sport) +- **Organization roles** — `player`, `tournament_director`, `referee`, + `scorekeeper`, `platform_admin` +- **Organization scopes** — `manage_tournaments`, `manage_matches`, + `manage_registrations`, `manage_users`, `read_all` +- **API scopes** registered on the `Court Command API` resource — + `read:tournaments`, `write:matches`, `read:admin`, etc. (12 total) +- **M2M app credentials** — `client_id` and `client_secret` for partner API + keys; the database stores only the `logto_m2m_app_id` reference, never + the secret +- **JWKS** (public keys), refresh tokens, sessions, webhook signing keys +- **Webhook subscriptions** (which events fire to which URLs) + +If you ever need a new role, scope, or organization, you go to +`https://logto-admin.courtcommand.app`, change it there, and update +`docs/LOGTO_SETUP.md` so the next operator can reproduce it. **No migration.** + +### Zone C — Hybrid (the bridge) + +Some Court Command business facts reference a Logto identity. The relationship +is a Court Command concept; the identity is a Logto concept. + +Example: "user `v3hqe8jx4wnn` is the assigned referee for tournament `123`." + +- The **relationship** lives in Zone A (`tournament_staff` table → migration + required to add or change the schema of the relationship) +- The **identity being pointed at** lives in Zone B (a Logto user record → + no migration) +- The **bridge** is a foreign key column: `tournament_staff.user_id BIGINT + REFERENCES users(id)`, where `users` is the Court Command mirror table + whose `logto_user_id` points to the Logto record + +When you add a new bridge table — say, `league_memberships(user_id, league_id, +role)` — you write a migration for the table itself (Zone A), but the meaning +of "role" depends on whether you're modelling a Court Command relationship +(Zone A: it's a string column) or reusing a Logto org role (Zone B: don't +duplicate; reference by name and check via JWT claim). + +--- + +## Quick decision tree + +> Should I write a database migration for this change? + +1. **Am I changing the *shape* of stored data?** (new table, new column, + changed constraint, new index, dropped anything) + - **No** → no migration. You're inserting/updating rows; that's + application code. + - **Yes** → continue. +2. **Is this Court Command business data?** (tournaments, matches, venues, + player profile, registrations, app settings, etc.) + - **Yes** → migration in `api/db/migrations/`. +3. **Is this an identity, role, scope, or organization concept?** + - **Yes** → no migration. Configure in Logto admin + (`https://logto-admin.courtcommand.app`), update `docs/LOGTO_SETUP.md`. +4. **Is it a relationship between a Logto identity and a Court Command + entity?** + - **Yes** → migration for the relationship table; the user FK references + `users(id)` (the mirror), not Logto directly. + +## Examples + +| Change | Migration? | Why | +|---|---|---| +| Add a `tournaments.banner_url` column | ✅ | Zone A schema change | +| Add a new sport (e.g. Basketball) | ✅ + Logto | Insert a row into `sports` (Zone A — done via migration since it's a lookup seed); also create a `Basketball` org in Logto admin (Zone B) | +| Add a new player profile field (e.g. `preferred_court_surface`) | ✅ | Zone A — new column on `player_profiles` | +| Insert a new tournament via the admin UI | ❌ | Zone A row, not schema | +| Add a new Logto org role (e.g. `vendor`) | ❌ | Zone B — Logto admin UI | +| Add a new API scope (e.g. `read:billing`) | ❌ | Zone B — Logto admin UI on the API resource | +| Add a `tournament_staff` row when a TD creates a tournament | ❌ | Zone A row, not schema | +| Replace `tournament_staff.raw_password` with a Logto-managed account | ✅ | Zone A schema change (drop column) | +| Add a `league_memberships` table | ✅ | Zone A schema change | +| Suspend a user via the admin UI | ❌ | Zone B — Logto Management API call (the database mirror's `users` row is unchanged structurally) | +| Update a user's email after they change it in Logto | ❌ | Zone B — webhook updates the mirror row's `email` cache; no schema change | + +## Migration mechanics (Zone A only) + +For Zone A schema changes, the standard flow: + +1. Write a new migration in `api/db/migrations/NNNNN_descriptive_name.sql` + following the goose `-- +goose Up` / `-- +goose Down` convention. Use the + next available number. +2. Update `api/db/queries/*.sql` if needed. +3. Run `make sqlc-generate` (or the equivalent in this repo) to regenerate + `api/db/generated/`. +4. Update affected Go services and handlers. +5. Update affected TypeScript types in `web/src/lib/types.ts`. +6. Update any forms / UI that touch the field. +7. Run `go test ./...`, `pnpm tsc -b --noEmit`, `pnpm build` locally before + committing. +8. Push. Coolify runs the migration automatically on next deploy via + `db.RunMigrations(ctx, cfg.DatabaseURL)` in `api/main.go`. + +`-- +goose Down` is mandatory and must actually reverse the change. The +schema-alignment audit (`docs/superpowers/audits/2026-04-20-db-schema-alignment.md`) +exists because of past failures here. diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000..b1db993 --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,627 @@ +# Court Command — Feature Inventory + +> **Purpose** +> Living catalog of every feature in Court Command. Updated on every commit +> that adds, removes, or changes feature surface. Use this as: +> - The smoke-test checklist before a release +> - The spec for any rewrite or major refactor +> - The shared mental model for what the product does +> +> **How to update** +> When a commit changes feature surface, edit this file in the SAME commit. +> Mark each line with a status emoji (legend below). Add a `(commit-sha)` +> reference for the commit that introduced or last changed it when useful. + +## Status legend + +- ✅ **Working** — implemented, tested, no known bugs +- ⚠️ **Partial / regressed** — exists but has known limitations or is broken on a subset of paths +- ❌ **Broken** — code exists but doesn't function; needs fixing +- 🚧 **In progress** — actively being built +- 📋 **Planned** — on the roadmap, not yet implemented +- 🗑️ **Removed** — was here, has been deleted (kept on the list as a historical reference for one release cycle, then drop) + +--- + +## Last updated +- **Branch:** `feature/logto-integration` +- **Date:** 2026-05-03 +- **Latest commit at update:** `90f8717` (Sidebar SportProvider lift) + +--- + +## 1. Public / Anonymous Experience + +Routes that work without sign-in. The public face of the product. + +### Landing & Discovery +- ✅ Public landing page (`/`) — hero, news widget, public directories +- ✅ Public top bar + bottom tabs (mobile-app style nav for anonymous visitors) +- ✅ News widget pulling from Ghost CMS (`news.courtcommand.app`) +- ✅ Sign-in CTA from PublicHero — kicks off Logto OIDC flow +- ✅ Switch sport / re-pick (multi-sport users) + +### Public Directories +- ✅ Public tournaments directory (`/public/tournaments`) — list with filters +- ✅ Public tournament detail (`/public/tournaments/:slug`) — divisions, schedule, results +- ✅ Public leagues directory (`/public/leagues`) +- ✅ Public league detail (`/public/leagues/:slug`) +- ✅ Public venues directory (`/public/venues`) +- ✅ Public venue detail (`/public/venues/:slug`) +- ✅ Public events feed (`/public/events`) — combined tournaments + leagues +- ✅ Public live page (`/public/live`) — currently live matches across the system + +### Public Match Views +- ✅ Public match detail (`/$sport/matches/:publicId`) — viewable without auth +- ✅ Public match scoreboard (`/$sport/matches/:publicId/scoreboard`) — fullscreen, no shell, for projection / kiosk +- ✅ Public match-series detail (`/$sport/match-series/:publicId`) + +### Public Search +- ✅ Search modal (Cmd-K) — searches across tournaments, leagues, venues, players, teams +- ✅ Search results grouped by entity type +- ✅ Search context (autocomplete, recent searches) + +--- + +## 2. Authentication & Identity + +### Sign-in / sign-up +- ✅ Logto OIDC sign-in flow +- ✅ Email-based sign-in (configured by seeder; default Logto template would require username-only) +- ✅ Sign-up via Logto hosted form (email + password, no email verification in dev) +- ✅ OIDC callback handler (`/auth/callback`) — code exchange + post-redirect from sessionStorage +- ✅ Sign-out — calls Logto `signOut`, returns to `/` +- ✅ JWT token attachment to all API calls (Bearer header from `apiFetch`) +- ✅ Org-scoped JWT — `getAccessToken(resource, organizationId)` produces a token with both API resource scopes and org audience + +### User mirror sync +- ✅ Logto webhook handler (`POST /api/v1/webhooks/logto`) — HMAC-SHA-256 verified +- ✅ Webhook handles `User.Created`, `User.Data.Updated`, `User.Deleted` +- ✅ On-demand mirror middleware — fetches from Logto Mgmt API and upserts on first JWT request if no local row exists +- ✅ JWT-session bridge — populates `session.Data` from JWT claims for legacy handler compatibility + +### User profile +- ✅ GET `/api/v1/auth/me` — returns the local users mirror row (JWT-protected) +- ✅ GET `/api/v1/me/profile` — returns `player_profiles` row (or empty DTO) +- ✅ PATCH `/api/v1/me/profile` — partial update with COALESCE narg pattern, requires `write:profile` scope +- ✅ Profile edit form (`/$sport/profile`) — 8 sections, all 25 player_profiles fields: + - Contact (phone) + - Pickleball Identity (DUPR ID, VAIR ID) + - Equipment (paddle brand, paddle model) + - Demographics (gender, handedness, date of birth, bio) + - Address (line 1, line 2, city, state/province, country, postal code) + - Emergency Contact (name, phone) + - Medical (notes) + - Privacy (hide from public directories) +- ✅ Sidebar "Switch sport" link + +### Sessions / impersonation +- ✅ Cookie session path — kept as fallback for testutil server only (production uses JWT) +- ⚠️ **Impersonation / masquerade — currently non-functional under JWT, restoration path is Logto-native.** Existing backend code (`StartImpersonation`, `StopImpersonation`, `Impersonator*` fields on session.Data) is cookie-tied and doesn't see the JWT path. The correct fix is **NOT custom claim-stuffing** — Logto provides first-class impersonation via OAuth 2.0 Token Exchange (RFC 8693, see [docs](https://docs.logto.io/developers/user-impersonation)): + 1. Backend admin endpoint validates "Sarah can impersonate Alex," then calls Logto Mgmt API `POST /api/subject-tokens` with `userId=alex` and a `context` object (ticket ID, reason, etc.) — returns a 10-min single-use `subjectToken`. + 2. Frontend exchanges the subject token at Logto's `/oidc/token` with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` + `subject_token` + `actor_token=` → receives an access token where `sub=alex` and `act.sub=sarah`. + 3. SPA stashes the impersonation token, `apiFetch` uses it for all subsequent requests. Backend RequireJWT validates it normally; user mirror sync flows naturally. The `act` claim is the audit signal. + 4. Stop impersonation = discard the impersonation token, revert to admin's regular token. + + Prerequisites: + - Enable "Allow token exchange" on the SPA app in Logto (one-time toggle; seeder can apply via Mgmt API). + - Admin endpoint must enforce "platform_admin only" + log to `activity_logs` for audit. + - Frontend `ImpersonationBanner` reads the `act` claim from the current access token to render. + +### Roles & Permissions +- ✅ Logto org roles: `player`, `tournament_director`, `referee`, `scorekeeper`, `platform_admin` +- ✅ Logto API resource scopes: `read:profile`, `write:profile`, `read:tournaments`, `write:tournaments`, `read:matches`, `write:matches`, `read:registrations`, `write:registrations`, `read:overlay`, `write:overlay`, `read:admin`, `write:admin` (12 total) +- ✅ Logto org scopes: `manage_tournaments`, `manage_matches`, `manage_registrations`, `manage_users`, `read_all` (5 total) +- ✅ Bootstrap admin granted `Court Command API (all scopes)` user-role with all 12 resource scopes (via seeder) +- ✅ Local `users.role` derivation from JWT claims (`elevatedRoleFromClaims`) — promotes to platform_admin when org claim present +- ⚠️ Other role mapping (TD, ref, scorekeeper) NOT yet derived from claims — local row defaults to `player` until manually patched + +--- + +## 3. Sport Routing & Multi-Tenancy + +Multi-sport architecture: tournaments/leagues/venues are scoped per sport via `sport_id` columns and Logto orgs. + +- ✅ `GET /api/v1/sports` — public endpoint, returns active sports +- ✅ `sports` table seeded with Pickleball + Demo Sport (Demo Sport hidden in production launch mode) +- ✅ Single-sport launch mode (`VITE_AUTO_REDIRECT_SINGLE_SPORT=true`, `SEED_DEMO_SPORT=false` in prod) +- ✅ Sport picker UI (multi-sport mode) — at `/`, only renders when authenticated AND multiple active sports +- ✅ `$sport` URL segment for all sport-scoped routes (`/pickleball/dashboard`, `/pickleball/leagues`, etc.) +- ✅ SportProvider — globally mounted, derives slug from URL, exposes `useSport()` hook +- ✅ X-Sport HTTP header attached to API calls by `apiFetch` based on current URL +- ✅ Backend RequireSportMatchesJWT middleware exists (Phase 1) but **NOT yet chained on protected routes** — cross-sport URL editing is a known data-leak window +- ✅ SportGuard bounces unknown slugs to `/` (the picker) + +--- + +## 4. Tournaments + +`tournaments` table + `divisions` + `pods` + `tournament_courts` + `tournament_staff`. + +### Lifecycle +- ✅ Create tournament (`/$sport/tournaments/create`) +- ✅ Tournament detail (`/$sport/tournaments/:tournamentId`) — overview, divisions, courts, staff, settings tabs +- ✅ Tournament list (`/$sport/tournaments`) +- ✅ Tournament settings tab — edit name, description, dates, scoring presets, etc. +- ✅ Tournament clone — duplicate a tournament with all divisions + settings +- ✅ Tournament publishing/lifecycle (status: draft, registration_open, in_progress, complete) +- ✅ Tournament announcements (`tournaments.announcements` route group) +- ✅ Soft-delete (deleted_at) + +### Divisions +- ✅ Division CRUD (`/$sport/tournaments/:tournamentId/divisions/:divisionId`) +- ✅ Division registrations (table view) +- ✅ Division seeds (manual ordering of teams) +- ✅ Division bracket (auto-generated from registrations) +- ✅ Division overview, detail, list components +- ✅ Division forms (create, edit) + +### Pods (group-stage pools) +- ✅ Pod CRUD +- ✅ Pod-to-team assignment +- ✅ Pod scheduling (matches across pod members) + +### Courts +- ✅ Tournament-to-court assignment (`tournament_courts` junction table) +- ✅ Assign existing venue court to tournament +- ✅ Create temp court for tournament (one-off) +- ✅ Unassign court from tournament + +### Staff +- ✅ Tournament staff invites (`tournament_staff` table) — TD, head ref, ref, scorekeeper, broadcast operator roles +- ✅ Staff regenerate token (resend invite) +- ✅ Staff list per tournament +- ⚠️ Tournament staff sign-in flow — handler exists but cookie-tied; needs Phase 4 review under JWT + +### Tournament-level features +- ✅ Tournament announcements (text post + scheduled visibility) +- ✅ Tournament public view (slug-routed) +- ✅ Tournament directory filters (sport, status, date range) + +--- + +## 5. Leagues & Seasons + +`leagues` + `seasons` + `division_templates` + `season_confirmations` + `league_registrations`. + +- ✅ Create league (`/$sport/leagues/create`) +- ✅ League detail (`/$sport/leagues/:leagueId`) +- ✅ League list (`/$sport/leagues`) +- ✅ League announcements feed +- ✅ League registrations (separate from tournament registrations) +- ✅ Seasons CRUD per league (`/$sport/leagues/:leagueId/seasons/:seasonId`) +- ✅ Season form (create, edit) +- ✅ Season list +- ✅ Season confirmations — players opt in to play a given season +- ✅ Division templates per league — reusable bracket/format definitions +- ✅ Standings view (`StandingsView`) + +--- + +## 6. Registry — Players, Teams, Organizations, Venues, Courts + +Long-lived domain entities not tied to a single tournament/season. + +### Players +- ✅ Player list (`/$sport/players`) — paginated, searchable +- ✅ Player detail (`/$sport/players/:playerId`) +- ✅ Player creation (legacy form via `PlayerForm.tsx` — uses `/api/v1/players/me`, cookie-only path; orphaned post-Phase-3, candidate for deletion in Phase 6) +- ✅ Shadow players (status='unclaimed', no Logto link) — created via TD workflow OR seed script +- 📋 Claim flow — when a real Logto user signs up matching an existing unclaimed user's email, merge rows. **Not yet implemented; Phase 4+.** + +### Teams +- ✅ Teams list (`/$sport/teams`) +- ✅ Team detail (`/$sport/teams/:teamId`) +- ✅ Team create (`/$sport/teams/new`) +- ✅ Team edit (`/$sport/teams/:teamId/edit`) +- ✅ Team rosters (`team_rosters` junction with player IDs) +- ✅ Team minimum-2-players validation + +### Organizations +- ✅ Org list (`/$sport/organizations`) +- ✅ Org detail (`/$sport/organizations/:orgId`) +- ✅ Org create (`/$sport/organizations/new`) +- ✅ Org edit (`/$sport/organizations/:orgId/edit`) +- ✅ Org memberships (`org_memberships` table — many-to-many users-to-orgs) +- ✅ Org blocks (`org_blocks` — orgs can block other orgs from registering in their tournaments) +- ✅ Org-team relationship (teams optionally belong to an org) + +### Venues +- ✅ Venue list (`/$sport/venues`) +- ✅ Venue detail (`/$sport/venues/:venueId`) — info, courts, upcoming matches, stream embeds +- ✅ Venue create (`/$sport/venues/new`) +- ✅ Venue edit (`/$sport/venues/:venueId/edit`) +- ✅ Venue managers (`venue_managers` junction — users with edit permission on a venue) +- ✅ Venue address with Google Maps integration (`MapView` component) +- ✅ Address standardization (formatted_address, lat/long via Maps API) +- ✅ Stream embed support (`StreamEmbed` component for venue + court live streams) + +### Courts +- ✅ Court list (`/$sport/courts`) +- ✅ Court detail (`/$sport/courts/:courtId`) — info, current match, queue, recent results +- ✅ Standalone/floating courts (not tied to a tournament) +- ✅ Court live status (available, occupied, maintenance) +- ✅ Court queue (queue of upcoming matches per court) +- ✅ Court overlay configuration (per-court broadcast settings) + +--- + +## 7. Match Operations + +`matches` + `match_events` + `match_series` + `tournament_matches` (via tournament_courts). + +### Match lifecycle +- ✅ Match detail page (`/$sport/matches/:publicId`) +- ✅ Match info panel (court, teams, time, status) +- ✅ Match hero (live score, key stats) +- ✅ Match status: `preparing`, `in_progress`, `completed`, `cancelled` +- ✅ Match completion — refs see Rematch / Save & Exit / Delete options +- ✅ Match history is read-only (matches in `completed` status) +- ✅ Match scoreboard page (`/$sport/matches/:publicId/scoreboard`) — fullscreen, no shell, projection-friendly + +### Match events +- ✅ Event-sourced scoring — every point/serve/timeout/etc. emits an event +- ✅ Event timeline view +- ✅ Undo last event (restore from snapshot) +- ✅ Score override modal (admin/TD recovery) +- ✅ Game over confirmation modal +- ✅ Match complete banner + +### Match series +- ✅ Match-series creation per division (best-of-N) +- ✅ Match-series detail (`/$sport/match-series/:publicId`) +- ✅ Series score tracking +- ✅ Series-aware overlay element + +### Quick Match +- ✅ Quick match list (`/$sport/quick-match`) +- ✅ Quick match create (`/$sport/quick-match/new`) +- ✅ Quick match card on dashboard +- ✅ Auto-cleanup of stale quick matches (background job, hourly) + +--- + +## 8. Scoring — Engine + UI + +Pickleball-specific scoring logic + UI for officials. + +### Engine +- ✅ Pickleball rules engine — server rotation, side-out, point scoring, win-by-2 +- ✅ Custom scoring presets (game-to-X, win-by-N, sideout/rally, etc.) +- ✅ Best-of-N games support +- ✅ Match contract test (engine isolation) + +### Referee Console +- ✅ Ref home (`/$sport/ref`) — list of assigned courts +- ✅ Ref court view (`/$sport/ref/courts/:courtId`) +- ✅ Ref match console (`/$sport/ref/matches/:publicId`) — full scoring UI +- ✅ Court grid (multi-court overview for venues) +- ✅ Live scoring buttons (Side Out, Point) +- ✅ Serve indicator (which player serves next) +- ✅ Timeout badge / track timeouts +- ✅ Score call display +- ✅ Game history bar (game scores so far) +- ✅ Match setup (assign teams + format before start) +- ✅ Lazy match configuration (refs can edit teams mid-match if needed) +- ✅ Disconnect banner (websocket dropped) +- ✅ Keyboard shortcuts for fast scoring +- ✅ Scoring preferences (per-user UI prefs) + +### Scorekeeper Console +- ✅ Scorekeeper home (`/$sport/scorekeeper`) +- ✅ Scorekeeper match console (`/$sport/scorekeeper/matches/:publicId`) +- ✅ Read-only scoreboard view + event timeline +- ✅ Suggest score corrections to ref + +### Real-time +- ✅ WebSocket match subscription (`useMatchWebSocket`) +- ✅ Court-level subscription (multiple matches on a court) +- ✅ Auto-reconnect on dropout +- ✅ WebSocket broadcast from backend on event create + +--- + +## 9. Brackets & Court Queue + +- ✅ Bracket auto-generation from division registrations +- ✅ Bracket court assignment (which courts host which round) +- ✅ Bracket snapshot for overlay +- ✅ Court queue position tracking +- ✅ Auto-advance teams through bracket on match completion + +--- + +## 10. Standings + +`standings_entries` table. + +- ✅ Per-division standings calculation +- ✅ Per-season league standings +- ✅ Standings view component (sortable table with W/L/Pts) +- ✅ Tiebreaker rules (point differential, head-to-head) +- ✅ Standings refresh on match completion + +--- + +## 11. Broadcast — Overlay System + +`court_overlay_configs` + `source_profiles` + `themes` (JSON-defined). A complete OBS browser-source / kiosk system. + +### Renderer +- ✅ Per-court overlay URL (`/overlay/court/:slug`) +- ✅ Demo overlay (`/overlay/demo/:themeId`) — for theme preview without a live match +- ✅ Transparent background composite +- ✅ Real-time WebSocket-driven updates +- ✅ Element scale control (responsive to OBS canvas size) +- ✅ Fade mount/unmount transitions + +### Element library +- ✅ Scoreboard (live match score, server indicator, set count) — null-safe on idle courts (no live match) since `useOverlayData` normalizes `team_*.players: null` → `[]` +- ✅ Element configs (all 12 keys) are normalized in `useOverlayConfig` — backend's `config.elements` can omit keys (newly-created courts, newly-added element kinds before migration), the hook fills in `{visible: false}` defaults so every renderer can safely access `config.elements..visible` +- ✅ Lower third (player names, sponsor) +- ✅ Player card (info card with photo + stats) +- ✅ Team card (team logo + roster) +- ✅ Pool standings (round-robin results) +- ✅ Bracket snapshot (current bracket state) +- ✅ Series score (best-of-N tracker) +- ✅ Match result (final score banner) +- ✅ Sponsor bug (logo overlay) +- ✅ Tournament bug (tournament name/branding) +- ✅ Custom text element (TD-defined messages) +- ✅ Coming up next (queued match preview) + +### Control panel +- ✅ Overlay control panel (`/overlay/court/:slug/settings`) +- ✅ Elements tab — toggle which elements are visible +- ✅ Theme tab — choose theme (12 elements styled by JSON config) +- ✅ Source tab — which match/court/series feeds the elements +- ✅ Triggers tab — manual fire of one-off elements +- ✅ Overrides tab — replace data fields (e.g., custom player name) +- ✅ OBS URL tab — generated browser-source URL with token +- ✅ Token-protected overlay URLs (rotation, generate, revoke) + +### Source profiles +- ✅ Source profile editor — reusable broadcast scene config +- ✅ Source profile list +- ✅ Per-court source profile binding + +### Producer Monitor +- ✅ Producer monitor (`/overlay/monitor`) — TD-facing view of all live overlays +- ✅ Court monitor card per active court +- ✅ Real-time status of each broadcast source + +### Setup Wizard +- ✅ Setup wizard (`/overlay/setup`) — guided flow for first-time broadcast setup +- ✅ Walks through: source profile → theme → elements → court binding → URL generation + +--- + +## 12. TV / Kiosk Displays + +Fullscreen, no-shell, public-facing displays for venues. + +- ✅ TV tournament display (`/tv/tournaments/:id`) — bracket + scores + sponsor rotation +- ✅ TV court display (`/tv/courts/:slug`) — single court fullscreen +- ✅ Slide rotation (`useSlideRotation`) — auto-cycle through views +- ✅ TVKiosk bracket / TVKiosk court components + +--- + +## 13. Dashboard (Authenticated User Home) + +`/$sport/dashboard` — landing for authenticated users. + +- ✅ Welcome header with user name +- ✅ Stats summary (career W/L, recent activity) +- ✅ Active registrations (tournaments user is registered for) +- ✅ Upcoming matches (next scheduled matches) +- ✅ My Teams (teams user belongs to) +- ✅ Recent results (last completed matches) +- ✅ Dashboard announcements (tournament/league/system-wide broadcast) +- ✅ Manage hub (`/$sport/manage`) — TD-facing aggregate of tournaments, leagues, orgs the user manages + +--- + +## 14. Admin Platform + +Platform-admin-only console for managing the system. `/$sport/admin/*` + +### User management +- ✅ User search (`/$sport/admin/users`) +- ✅ User detail (`/$sport/admin/users/:userId`) +- ✅ User edit (role, status changes) +- ⚠️ User impersonation (start) — handler exists but broken end-to-end under JWT (see §2) +- ⚠️ User impersonation (stop) — same issue +- ✅ Role assignment (player, TD, ref, scorekeeper, platform_admin) +- ✅ User status management (active, suspended, banned, unclaimed, merged, deleted) + +### System monitoring +- ✅ Activity log (`/$sport/admin/activity`) — audit trail of admin actions +- ✅ Activity log filters (action type, user, date range) + +### Settings +- ✅ Site settings page (`/$sport/admin/settings`) +- ✅ Settings persistence (`site_settings` key/value table) +- ✅ Ghost CMS integration toggle +- ✅ Google Maps API key +- ✅ Public site display preferences + +### Uploads +- ✅ Upload browser (`/$sport/admin/uploads`) +- ✅ Image upload component (used across forms) +- ✅ Orphaned upload cleanup (background job, daily) + +### Ad management +- ✅ Ad manager (`/$sport/admin/ads`) +- ✅ Ad config CRUD (`ad_configs` table) +- ✅ Ad display duration per slot +- ✅ Public ad slot rendering (`AdSlot` component) +- ✅ Ad rotation logic + +### API keys +- ✅ API key manager (`/$sport/admin/api-keys`) +- ✅ Key generation (random + bcrypt-hashed storage) +- ✅ Key revocation (is_active flag) +- ✅ Optional binding to Logto M2M app ID + +### Venue approval +- ✅ Venue approval UI (`/$sport/admin/venues`) — TDs submit, admin approves +- ✅ Approval workflow (pending → approved/rejected) + +--- + +## 15. Webhooks & Integrations + +- ✅ Logto webhook (`/api/v1/webhooks/logto`) — User.Created/Updated/Deleted +- ✅ HMAC-SHA-256 signature verification +- ✅ Idempotent upsert via UserSyncService +- ✅ Generic court webhook (`/api/v1/overlay/webhook/:courtID`) — TBD purpose, accepts external triggers +- 📋 Stripe webhook (deferred to billing phase) + +--- + +## 16. Real-Time / WebSocket + +- ✅ WebSocket gateway (`/api/v1/ws`) +- ✅ Match subscription channel +- ✅ Court subscription channel +- ✅ Auto-broadcast on match event create +- ✅ Reconnect logic on client (handled in `useWebSocket` / `useMatchWebSocket`) + +--- + +## 17. Cross-Cutting UX + +- ✅ Sidebar (collapsible, mobile-responsive) +- ✅ Theme system (light/dark/system) via `ThemeToggle` +- ✅ Toast notifications (success/error/info) +- ✅ Confirm dialog component +- ✅ Modal component +- ✅ Pagination +- ✅ Skeleton loaders +- ✅ Error boundary (per-route) +- ✅ Offline banner +- ✅ Install banner (PWA) +- ✅ Update prompt (PWA) +- ✅ Service worker (PWA, Workbox) +- ✅ Skip-to-content accessibility link +- ✅ Form components (Input, Textarea, Select, FormField, DateInput, AddressInput, ImageUpload) +- ✅ Avatar component (initials fallback) +- ✅ Badge / StatusBadge +- ✅ Card component +- ✅ EmptyState component +- ✅ TabLayout component +- ✅ Table component +- ✅ Search input +- ✅ ScoringPresetPicker +- ✅ VenuePicker +- ✅ SponsorEditor +- ✅ MapView (Google Maps embed) +- ✅ NewsWidget (Ghost CMS) +- ✅ Rich text display (markdown rendering) + +--- + +## 18. Backend Cross-Cutting + +- ✅ Health endpoint (`/api/v1/health`) — db + redis + build info +- ✅ Slog structured logging (production JSON, dev human) +- ✅ Request ID propagation +- ✅ CORS middleware (configurable allowed origins; X-Sport in allow-headers) +- ✅ JWT validator (jwx/v3, JWKS cache with stale fallback, ErrJWKSUnavailable sentinel for 503 vs 401) +- ✅ JWT-session bridge (claims → session.Data) +- ✅ Optional JWT (mixed-auth routes — Phase 3.6) +- ✅ MirrorUser middleware (on-demand Logto user mirror) +- ✅ Activity log service (audit writer) +- ✅ Slug service (URL-friendly generation) +- ✅ Search service (full-text across entities) +- ✅ Background jobs: + - Quick match cleanup (hourly) + - Upload orphan cleanup (daily, min 7d age) +- ✅ Goose migration runner (auto-applies on startup, blocks app start on failure) +- ✅ sqlc-generated query layer (~110 queries) +- ✅ Production fail-fast on missing required env vars (Phase 3.6) +- ✅ Logto Mgmt API client (cached M2M token, auto-refresh with safety margin) + +--- + +## 19. Local Dev Tooling + +- ✅ Docker Compose dev stack (`docker-compose.dev.yml`) — Postgres + Redis + Logto +- ✅ Logto seeder (`api/cmd/logto-seed/main.go`) — idempotent provisioner +- ✅ Seed.sql — 24 fixtures (1 admin + 17 unclaimed players + 6 staff, 3 orgs, 8 teams, 3 tournaments, etc.) +- ✅ `make dev-up` / `make dev-down` / `make dev-logs` / `make dev-reset` +- ✅ `make logto-seed` +- ✅ `make migrate-up` / `make migrate-down` / `make migrate-create` +- ✅ `make seed` (DB fixtures via dev compose) +- ✅ `make dev` (backend dev mode) +- ✅ `make build` (production binary) +- ✅ `make test` (Go test suite) +- ✅ Playwright E2E test (`pnpm e2e`) — full auth + dashboard + profile flow +- ✅ Bootstrap admin: `admin@courtcommand.local` / `TestPass123!` + +--- + +## 20. Phase 4+ Roadmap (Not Yet Implemented) + +Tracked here so they don't get lost. + +### Auth / identity +- 📋 Restore impersonation via Logto OAuth 2.0 Token Exchange (RFC 8693) — see §2 for the full flow. Concrete tasks: + 1. Enable "Allow token exchange" on SPA app via seeder (`PATCH /api/applications/:id` setting `customClientMetadata.allowTokenExchange=true`). + 2. Backend: `POST /api/v1/admin/users/:userId/impersonate` (platform_admin only, logs to activity_logs) — calls Logto Mgmt `POST /api/subject-tokens`, returns subject token to frontend. + 3. Backend: `POST /api/v1/admin/stop-impersonation` — no-op now (frontend handles), but keep for symmetry/audit log entry. + 4. Frontend: when admin clicks "Impersonate" in UserDetail, POST to backend, then exchange at Logto's `/oidc/token` for the impersonation access token, store separately from admin's token. + 5. Frontend `apiFetch`: prefer impersonation token over admin token when present. + 6. Frontend `useAuth`: detect `act` claim → expose `isImpersonating: true` + `impersonator: act.sub`. + 7. Frontend `ImpersonationBanner`: render whenever `act` claim is present, with "Stop impersonation" button that discards the impersonation token. + 8. Cleanup: delete the legacy session-cookie `Impersonator*` fields, `StartImpersonation`/`StopImpersonation` cookie handlers, `users.session.Data.Impersonator*` once the new path lands. +- 📋 Claim flow — merge unclaimed users with Logto-mirrored users by email match +- 📋 Map all 5 Logto org roles → local users.role (currently only platform_admin elevation works) +- 📋 Chain `RequireSportMatchesJWT` middleware on protected routes (cross-sport URL editing is a known data-leak window) + +### Performance / hardening +- 📋 Rate-limit Logto Mgmt API fetches per Subject (DoS amplification mitigation) +- 📋 Phase 6 cleanup — drop cookie code paths (RequireAuth, OptionalAuth, password_hash column, cookie session store, /auth/login, /auth/register, /auth/logout endpoints, legacy /players/me) +- 📋 Drop 3 documented seeder DB patches (move to Logto Mgmt API once those endpoints are confirmed in our SDK) +- 📋 Externalize webhook URL for prod (currently hardcoded `host.docker.internal:8080`) + +### Code quality +- 📋 Extract `splitName` to `internal/usersync` (currently duplicated in middleware/handler) +- 📋 Delete dead `PlayerForm` + legacy player hooks (orphaned post-Phase-3) +- 📋 Frontend code-quality review of feature directories (some may be larger than ideal) + +### Future product features (not yet started) +- 📋 Stripe billing integration (organization-level paid tiers) +- 📋 Multi-sport beyond Pickleball (Demo Sport is a placeholder; real second sport TBD) +- 📋 Mobile app (PWA → native via Capacitor or Expo) +- 📋 Advanced analytics dashboards +- 📋 Spectator-side notifications (subscribe to live matches) +- 📋 Replay clips / video integration (alongside StreamEmbed) +- 📋 DUPR sync (pull/push player ratings) +- 📋 VAIR sync (pull/push player ratings) +- 📋 Email notifications (registration confirmations, match reminders, etc.) +- 📋 SMS notifications + +--- + +## 21. Known Broken / Regressed (top priority for repair) + +- ⚠️ **Impersonation under JWT** (§2) — biggest feature regression from cookie → JWT migration. Restoration path is Logto-native via OAuth 2.0 Token Exchange (RFC 8693); concrete 8-step plan in §20. Phase 4 priority. +- ⚠️ **Cross-sport data leak window** (§3) — RequireSportMatchesJWT not chained. Phase 4 priority. +- ⚠️ **Role mapping incomplete** (§2) — only platform_admin elevation works. TD/ref/scorekeeper org-role users land with `users.role='player'` until manually patched. +- 🚧 **Ref console scoped down too far** (§8) — smoke 11.5: missing the verbal-calls UI, full event log on the scoring page, and game-history bar. Currently looks like a slimmed-down scorekeeper console. Phase 4 redesign needed; expand the Ref console with verbal calls (let-call, re-do, fault, line call) + log view alongside scoring. +- 🚧 **Live stream embed on court detail** (§6) — smoke 8.15: backend marks court as live but no embedded video player rendered when no `stream_url` is set. Need a stream-config UI on Court detail (URL + type) for non-overlay use cases. +- 🚧 **Quick match seed empty** (smoke 10.1) — seed.sql doesn't create active quick matches. Cosmetic; users create one and it appears. +- 🚧 **API keys section may show fewer than seeded** (smoke 15.9) — user reported only 1 of 2 seeded keys visible. Investigate whether seed.sql actually inserts both, and whether listing query filters them. The `api_keys.logto_m2m_app_id` binding is optional; keys without it should still list. +- 🗑️ **Deferred / future product features** (smoke 11.5, 15.11) — VAIR rating API integration + SSO from CC dashboard, expanded ref console (verbal calls, full log). All Phase 4+. + +--- + +## 22. Removed (historical) + +(none yet — first release of this inventory) + +--- + +*End of inventory. Update on every feature-affecting commit.* diff --git a/docs/LOCAL_DEV.md b/docs/LOCAL_DEV.md new file mode 100644 index 0000000..71636ce --- /dev/null +++ b/docs/LOCAL_DEV.md @@ -0,0 +1,243 @@ +# Local Development + +Run the full Court Command stack on your machine: Postgres, Redis, and a +local Logto tenant in Docker; the Go backend and Vite frontend running +natively on the host for fast iteration. + +This setup mirrors production (per `docs/LOGTO_SETUP.md`) but uses a +local Logto container instead of the deployed `logto.courtcommand.app`, +so you can develop, test, wipe, and re-seed without affecting prod. + +## Prerequisites + +- Docker (any modern version with the `docker compose` plugin) +- Go 1.24+ +- Node 22+ and pnpm +- A free copy of the repo + +If any are missing on Linux: + +```bash +sudo pacman -S go nodejs npm docker docker-compose # CachyOS / Arch +sudo npm install -g pnpm +sudo systemctl enable --now docker +# Add yourself to the docker group so you don't need sudo for `docker`: +sudo usermod -aG docker $USER +# Log out and back in for the group change to take effect. +``` + +## First-run setup + +Step 1 is one-time-per-machine; after that, day-to-day workflow is just +steps 5 and 6. + +### Step 1 — Bring up the dev infra + +```bash +cp .env.example .env # only needed once; edit if you want +make dev-up +``` + +This launches three Docker containers: + +| Service | Port | Purpose | +|---|---|---| +| Postgres 17 | `localhost:5432` | Holds two databases: `courtcommand` (app) and `logto` (Logto's own state) | +| Redis 7 | `localhost:6379` | Pub/sub for real-time match updates (legacy session storage will be removed in Phase 6) | +| Logto 1.22 | `localhost:3001` (OIDC), `localhost:3002` (admin UI) | Self-hosted identity provider | + +Wait ~30 seconds for Logto to finish initializing its database. Watch +the readiness with `make dev-logs` if you're impatient. + +### Step 2 — Create the initial Logto admin account + +Open in a browser. Logto's first-run wizard +asks you to create an admin account; this is the operator (you), not a +Court Command user. Use any email/password — they're local-only. + +After the wizard, you land in the Logto admin dashboard. + +### Step 3 — Create the Management API M2M app + +The seeder needs Management API credentials to provision everything +else. Logto can't bootstrap this one for you; you create it once via +the admin UI. + +In Logto admin (): + +1. **Applications → Create application → Machine-to-machine** + - Name: `Court Command Backend` + - Description: anything + - Click **Create**. +2. On the new app's detail page, click the **Roles** tab. +3. Click **Assign Logto roles** → tick **`Logto Management API access`** → save. +4. Click the **Settings** tab and copy: + - **App ID** (alphanumeric string) + - **App Secret** (longer alphanumeric string) + +Paste both into your `.env`: + +```bash +LOGTO_MANAGEMENT_API_APP_ID= +LOGTO_MANAGEMENT_API_APP_SECRET= +``` + +### Step 4 — Run the seeder + +```bash +make logto-seed +``` + +This script idempotently creates everything else: the SPA app, the +Court Command API resource with its 12 scopes, the organization +template (5 roles + 5 scopes + role-scope mappings), the Pickleball +and Demo Sport organizations, the bootstrap admin user, and the +webhook subscription. Re-running it is safe; existing items are +detected and reused. + +The script prints a summary at the end with the values you need to +paste back into `.env`: + +``` +LOGTO_PICKLEBALL_ORG_ID=... +LOGTO_DEMO_SPORT_ORG_ID=... +LOGTO_WEBHOOK_SIGNING_KEY=... +VITE_LOGTO_APP_ID=... +``` + +Copy those four lines into your `.env`. + +### Step 5 — Start the backend + +```bash +make dev +``` + +This runs the Go backend natively at . The +backend connects to the Dockerized Postgres + Redis + Logto, runs all +migrations on startup (you'll see goose log lines), and serves +`/api/v1/health`. + +Sanity check in another terminal: + +```bash +curl -s http://localhost:8080/api/v1/health | jq +# { +# "build": { "commit": "dev", "built_at": "unknown" }, +# "services": { "database": "ok", "redis": "ok" }, +# "status": "ok" +# } +``` + +### Step 6 — Start the frontend + +```bash +make dev-frontend +``` + +Vite serves the SPA at . After Phase 3 lands, +clicking "Sign in" will redirect you to +(Logto), where you sign in with the bootstrap admin credentials +(`admin@courtcommand.local` / `TestPass123!` by default), and you'll +land back on the SPA with a valid Logto JWT. + +## Day-to-day workflow + +After the one-time setup above: + +```bash +make dev-up # docker containers (db + redis + logto) +make dev & # backend +make dev-frontend # frontend (in another terminal, or as a background job) +``` + +To shut everything down at end of day: + +```bash +# Ctrl-C the backend and frontend processes, then: +make dev-down +``` + +## Useful commands + +```bash +make dev-up # start the dev infra +make dev-down # stop the dev infra (data persists) +make dev-reset # WIPE the dev infra including Postgres + Logto state +make dev-logs # tail logs from db / redis / logto +make logto-seed # idempotent Logto provisioning +make migrate-up # run pending DB migrations manually (the backend also + # does this on startup, so usually unnecessary) +make sqlc # regenerate sqlc bindings after editing + # api/db/queries/*.sql +make test # run all backend tests (creates a separate + # courtcommand_test database) +``` + +## Wiping and re-seeding from scratch + +If you mess something up in Logto or want a clean slate: + +```bash +make dev-reset +make dev-up +# then steps 2-4 of First-run setup +``` + +`dev-reset` removes the `pgdata_dev` Docker volume, which holds both the +`courtcommand` and `logto` databases. Migrations re-run on the next +`make dev` startup. The Logto admin account, the M2M app, the seeded +data — all gone. You'll go through the wizard and the seeder again. + +## Common problems + +### `make logto-seed` fails with "missing required env vars" + +You haven't copied `LOGTO_MANAGEMENT_API_APP_ID` and +`LOGTO_MANAGEMENT_API_APP_SECRET` into `.env` from Step 3 yet. + +### `make logto-seed` fails with `oidc.invalid_target` + +Your `LOGTO_MANAGEMENT_API_RESOURCE` is wrong. For self-hosted Logto the +correct value is `https://default.logto.app/api` — that's a Logto +internal audience identifier, not a real URL. The default in +`.env.example` is correct; verify nothing has changed it. + +### Backend startup fails with "failed to run migrations" + +Postgres might not be ready yet. The `db` service has a healthcheck and +the backend's connection string targets `localhost:5432`. If the +container is still initializing on a slow machine, wait 30 seconds and +retry. Run `docker compose -f docker-compose.dev.yml ps` to see service +state. + +### Webhook from Logto can't reach the backend + +The seeder registers the webhook URL as +`http://host.docker.internal:8080/api/v1/webhooks/logto` — Logto runs +in a Docker container, so it has to reach the natively-run backend +through Docker's host-gateway alias. The compose file sets up +`extra_hosts: host.docker.internal: host-gateway` on the Logto +service; if you're on macOS / Windows Docker Desktop this works +out of the box, on Linux Docker the host-gateway feature is enabled +by default in modern versions but check `docker info | grep -i +hosts` if you suspect issues. + +### Port conflict on 5432 / 6379 / 3001 / 3002 + +Another process is using those ports. Either stop it, or edit +`docker-compose.dev.yml` to remap (e.g., `15432:5432`). If you remap, +also update the matching ports in `.env`. + +## Going to production + +The production deployment doesn't use `docker-compose.dev.yml`. Coolify +runs the `api` and `web` services from the existing +`docker-compose.yaml` against the production Logto at +`logto.courtcommand.app`. See `docs/LOGTO_SETUP.md` for the production +Logto setup walkthrough. + +The seeder *can* be run against production Logto if you ever need to +re-seed (e.g., after a Logto database wipe). Set the production env +vars and run `make logto-seed` against them. The seeder is idempotent +and will not duplicate existing apps/resources/orgs/users. diff --git a/docs/LOGTO_RUNBOOK.md b/docs/LOGTO_RUNBOOK.md new file mode 100644 index 0000000..b97f94d --- /dev/null +++ b/docs/LOGTO_RUNBOOK.md @@ -0,0 +1,410 @@ +# Logto Runbook — Court Command + +Operational guide for the Logto integration. Where `docs/LOGTO_SETUP.md` +covers first-time provisioning, this file covers **runtime behavior, +known gotchas, debugging recipes, and the active TEMP-ADMIN-BYPASS +state**. + +If you arrived here because the admin sidebar link disappeared, jump +straight to "Common failure shapes." + +--- + +## 1. Architecture + +Logto, the api, and the SPA interact as follows: + +``` + ┌──────────────┐ + │ Browser │ (the SPA, served from courtcommand.app) + └──────┬───────┘ + │ + (1) authorize │ via @logto/react SDK + ▼ + ┌─────────────────────────────┐ + │ Logto (svhd/logto:1.22.0) │ identity provider, OIDC + │ logto.courtcommand.app │ + └──────┬──────────────────────┘ + │ + (2) issues access token (org-scoped) + │ + ▼ + ┌──────────────┐ + │ Browser │ + └──────┬───────┘ + │ + (3) Authorization: Bearer + ▼ + ┌──────────────┐ + │ api │ api.courtcommand.app + │ │ validates JWT against Logto JWKS + │ │ reads claims, mirrors user, etc. + └──────────────┘ +``` + +**Key facts** + +- The SPA is a Logto SPA-type app; the api is a JWT-protected resource server. +- Sport organizations in Logto map 1:1 to rows in the local `sports` + table via `sports.logto_org_id` (stored at first boot by the + auto-bootstrap, sync'd to Logto on every subsequent boot). +- `platform_admin` is a Logto org-role (not a global role) granted per + sport org. The api elevates `users.role` from the local DB to + `platform_admin` per-request when the JWT's `organization_roles` + claim contains `platform_admin`. +- The bootstrap admin's local row is created with `users.role='player'` + (the SQL default in migration 00041). The elevation is overlaid at + read time in `api/handler/auth.go:MeJWT`. + +### Files of interest + +| Path | Purpose | +| --------------------------------------------- | ---------------------------------------------------- | +| `api/main.go` | Boot sequence; calls `logtoseed.Run` + verifier | +| `api/logtoseed/` | Idempotent provisioning logic (was a CLI; now also auto-run) | +| `api/startup/verify_sports.go` | Belt-and-suspenders: every active sport's org ID must resolve in Logto | +| `api/auth/context.go` | `Claims` shape + `ElevatedRole()` mapping | +| `api/handler/auth.go:MeJWT` | Returns `users.role` overridden by `claims.ElevatedRole()` | +| `api/middleware/auth.go:RequirePlatformAdmin` | Backend admin gate (currently bypassed) | +| `api/cmd/logto-seed/main.go` | Thin CLI wrapper around `logtoseed.Run` | +| `web/src/auth/LogtoConfig.ts` | SDK config; scopes requested at sign-in | +| `web/src/auth/AuthProvider.tsx` | `` + token wiring | +| `web/src/auth/SportContext.tsx` | Maps URL slug → `logto_org_id` for org-scoped tokens | +| `web/src/auth/useAuth.ts` | `/me` query; gates on `!sportLoading` | +| `web/src/lib/api.ts` | `apiFetch` attaches Bearer token, requests org token | +| `web/src/features/admin/AdminGuard.tsx` | SPA admin route gate (currently bypassed) | +| `web/src/components/Sidebar.tsx` | Admin link visibility (currently bypassed) | +| `web/src/features/admin/bypass.ts` | `ADMIN_BYPASS_ACTIVE` flag for the warning banner | + +--- + +## 2. Boot sequence (api) + +On every api start, in order: + +1. `db.RunMigrations` runs goose migrations against the application DB. + Migration 00042 idempotently rewrites the stale 00041 hardcoded + `logto_org_id` values to `'pending-seed:'` placeholders. + +2. `logtoseed.Run` (when Logto Mgmt API creds are present) idempotently + provisions everything against the Logto tenant: API resource + + scopes, SPA app (drift-protected if `LOGTO_SPA_APP_ID` env is set), + M2M role assignment, org template, sport orgs, bootstrap admin + + `platform_admin` org-role assignment, email connector, sign-in + experience, webhook (drift-protected if `LOGTO_WEBHOOK_SIGNING_KEY` + env is set). Then `syncSportsOrgIDs` overwrites the placeholders + with the real Logto org IDs. Held under a Postgres advisory lock + so concurrent api boots don't race. + +3. `startup.VerifySportsOrgIDsFromDB` lists Logto orgs via the Mgmt + API and confirms every `is_active=true` row in `sports` references + an org that actually exists. In production, any problem fails the + boot (`os.Exit(1)`); in development, it logs warnings and + continues. + +4. Router mounts, http server starts, traffic begins. + +In production a successful boot prints these `slog` lines (paraphrased): + +``` +INFO logto seed starting endpoint=https://logto.courtcommand.app ... +INFO synced sports.logto_org_id pickleball_rows=1 demo_sport_rows=0 +INFO logto seed complete pickleball_org_id=vcx906e38a2v ... +INFO verified sports.logto_org_id against Logto tenant sports_checked=1 logto_orgs_listed=N +``` + +If you don't see these lines on api boot, the Logto integration is +not running -- check `LOGTO_MANAGEMENT_API_APP_ID/SECRET` are present +in env (`api/main.go:251-261` warns and skips when absent). + +--- + +## 3. Known races, gotchas, and design constraints + +### 3.1 SPA mount-order race (fixed in commit 971b11a) + +**Symptom**: `/me` returns `role: "player"` despite the user being +assigned `platform_admin` in Logto. + +**Cause**: `SportProvider` and `useAuth` mount in the same render. +`useAuth` enables the `/me` query immediately based on +`isAuthenticated`; `apiFetch` reads `currentOrgID` synchronously when +it builds the Authorization header. If `listSports()` hasn't resolved +yet, `currentOrgID === ''` and the Logto SDK is called with no orgID, +which issues a **resource-only token** (no `organization_id`, no +`organization_roles` claim). The api can't elevate, the response says +`role: "player"`, React Query caches that for 5 minutes. + +**Fix in `useAuth`**: query is `enabled: isAuthenticated && !sportLoading`, +and `sport?.slug` is part of the queryKey so navigating between sports +forces a refetch with the new org's elevation. + +### 3.2 `organization_roles` claim never appears in API-resource access tokens + +**This is by design in Logto and is documented behavior.** + +> The `urn:logto:scope:organization_roles` scope provides the user's +> roles within organizations [...] and is included in the **ID token** +> by default. These organization-related claims can also be accessed +> via the userinfo endpoint using an opaque token, but opaque tokens +> are not suitable for accessing organization-specific resources. +> +> — docs.logto.io + +Logto's API-resource access tokens (`aud: ` with an +`organization_id` context) carry ONLY API resource scopes in the +`scope` claim. The `organization_roles` claim is NEVER emitted on +these tokens regardless of which scopes were requested at sign-in. + +The api originally read `organization_roles` directly from the JWT +(`api/auth/context.go:ElevatedRole`) expecting Logto to populate it. +That assumption was wrong; elevation never fired. + +**Fix (commit 7d8b... or thereabouts)**: api/middleware/jwt_session +now has a Path-2 elevation step. When the JWT has `organization_id` +but no `organization_roles`, it asks the Logto Management API for the +user's roles in that org via a new `OrgRoleResolver` (see +`api/middleware/org_role_resolver.go`). Results are cached in Redis +with a configurable TTL (`LOGTO_ORG_ROLES_CACHE_TTL_SECONDS`, default +60s), so warm caches absorb the bulk of authenticated traffic. + +The original JWT fast path stays in place — if Logto ever does emit +the claim (or a JWT customizer is configured to inject it), we skip +the Mgmt API call automatically. + +### 3.3 Org-ID drift across Logto tenants (fixed in commit fb81fae + b68e940) + +Logto generates org IDs randomly at creation time, so the IDs hardcoded +in migration 00041 (`ekup1zyrrxj4`, `7866ex96uk6b`) were correct only +for the original developer's local Logto. Every other tenant has +different IDs. The auto-bootstrap on every api boot resolves this +because `syncSportsOrgIDs` writes whatever IDs Logto actually returned +into the local `sports` table. + +If a fresh deploy ever produces stale IDs again, look at the api boot +log for `logto seed` errors -- the seeder is the only path that +populates that table after migration 00042. + +### 3.4 Token caching (SDK + React Query) + +Two layers cache the access token / `/me` response: + +1. **Logto SDK** stores tokens in `localStorage` under keys prefixed + with `logto:` and reuses them until they expire. +2. **React Query** caches `/me` for 5 minutes (`staleTime` in + `useAuth.ts`). + +After a config change that affects token *shape* (e.g. adding a scope), +a refresh is NOT enough. You must sign out (which calls the SDK's +`signOut` and clears its storage) AND let the `/me` query stale out, +OR clear localStorage manually: + +```js +// DevTools console +Object.keys(localStorage) + .filter(k => k.startsWith('logto:')) + .forEach(k => localStorage.removeItem(k)) +sessionStorage.clear() +``` + +For a guaranteed clean test, use an **incognito window**. + +### 3.5 Build-time vs runtime envs + +These are baked into the SPA image at build time and require a web +rebuild to change: + +- `VITE_LOGTO_ENDPOINT` +- `VITE_LOGTO_APP_ID` +- `VITE_LOGTO_API_RESOURCE` +- `VITE_AUTO_REDIRECT_SINGLE_SPORT` + +These can be flipped via Coolify env without a rebuild (api reads them +at startup): + +- `LOGTO_ENDPOINT` +- `LOGTO_API_RESOURCE` +- `LOGTO_MANAGEMENT_API_APP_ID` / `_SECRET` / `_RESOURCE` +- `LOGTO_BOOTSTRAP_EMAIL` / `_PASSWORD` / `_NAME` +- `LOGTO_WEBHOOK_SIGNING_KEY` +- `LOGTO_SPA_APP_ID` (drift protection for the seeder) +- `SEED_DEMO_SPORT` +- SMTP creds for the email connector + +--- + +## 4. Debugging recipes + +### 4.1 Decode a JWT in one line + +```sh +TOKEN='eyJhbG...' # from Authorization: Bearer header +echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq +``` + +### 4.2 Expected claims + +| Claim | When present | Meaning | +| --------------------- | ----------------------------------------------- | -------------------------------- | +| `aud` | always | API resource indicator OR `urn:logto:organization:` for org-scoped | +| `organization_id` | org-scoped tokens only | Sport org user is currently scoped to | +| `organization_roles` | org-scoped tokens + `OrganizationRoles` scope | Role names (e.g. `["platform_admin"]`) | +| `scope` | always | Space-separated API + user scopes | +| `sub` | always | Logto user ID | + +If `organization_id` is **missing**: the SPA requested a resource-only +token. Diagnose with the mount-order race (3.1). + +If `organization_id` is **present** but `organization_roles` is +**missing**: see 3.2 and section 6. + +### 4.3 Capture what scopes the SDK is requesting + +1. Sign out. +2. Open DevTools → Network → enable "Preserve log". +3. Click sign in. +4. Look for `GET https://logto.courtcommand.app/oidc/auth?...` (the + first request before the redirect). +5. Decode the `scope=` query param. Expected scopes today: + - `openid profile email` + - `urn:logto:scope:organizations` (orgs in token) + - `urn:logto:scope:organization_roles` (role names in token) + - The 12 Court Command API scopes (`read:profile write:profile ...`) + +### 4.4 Verify Logto state directly via the Mgmt API + +```sh +# Get an M2M token +TOKEN=$(curl -sS -X POST "https://logto.courtcommand.app/oidc/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -u "${LOGTO_MANAGEMENT_API_APP_ID}:${LOGTO_MANAGEMENT_API_APP_SECRET}" \ + -d "grant_type=client_credentials&resource=https://default.logto.app/api&scope=all" \ + | jq -r .access_token) + +# List orgs (to confirm the one in your DB exists) +curl -sS -H "Authorization: Bearer $TOKEN" \ + "https://logto.courtcommand.app/api/organizations" | jq '.[].id,.[].name' + +# Confirm a user has platform_admin in a sport org +curl -sS -H "Authorization: Bearer $TOKEN" \ + "https://logto.courtcommand.app/api/organizations//users//roles" | jq +``` + +### 4.5 Verify api state directly + +```sh +# Health +curl -sS https://api.courtcommand.app/api/v1/health | jq + +# What sports does the api expose? (Should match Logto orgs) +curl -sS https://api.courtcommand.app/api/v1/sports | jq + +# /me (replace TOKEN with the Bearer from your browser's request) +curl -sS https://api.courtcommand.app/api/v1/auth/me \ + -H "Authorization: Bearer $TOKEN" | jq +``` + +--- + +## 5. Common failure shapes + +| Symptom | Diagnosis | Fix | +| --------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | +| No Admin link in sidebar | `user.role !== 'platform_admin'` in `/me` | Verify elevation. See 5.1 below. | +| Sidebar collapsed; can't tell if Admin is there | Admin renders as Shield icon only when collapsed | Expand the sidebar (chevron at top). | +| `aud` is the API resource, no `organization_id` | Mount-order race, OR you're on `/` (no sport) | Navigate to `//...`. Already fixed for the race. | +| `organization_id` present, `organization_roles` absent | SDK didn't request `OrganizationRoles` scope OR Logto version doesn't honor it | See 3.2 and section 6. | +| /me returns 404 user mirror not found | Logto webhook hasn't fired yet for this user | Wait a few seconds; or the JWTSession middleware mirrors on demand. | +| api refuses to start, "verification failed" | `sports.logto_org_id` points at a missing Logto org | Check `logto seed` logs; the seeder should have fixed this. If not, the Mgmt API creds may be wrong. | +| Sign-in loop | api returning 401 on `/me`; SPA treats as logged-out, redirects to sign-in | Check the api log for JWT validation errors. | + +### 5.1 Why is `/me` returning `role: "player"` when I have `platform_admin` in Logto? + +Five things to check, in order: + +1. **Token has `organization_id`?** If not -> mount-order race (3.1) or + you're on a non-sport URL. +2. **Token has `organization_roles`?** If not -> scope issue (3.2). See + section 6. +3. **Logto Mgmt API shows you with `platform_admin` in that org?** If + not -> the seeder didn't run (check api boot log) or the bootstrap + email in `LOGTO_BOOTSTRAP_EMAIL` doesn't match your real email. +4. **`api/auth/context.go:ElevatedRole()` looking for `platform_admin`?** + It is (verify the code hasn't drifted). +5. **`MeJWT` applying the elevation?** (`api/handler/auth.go:178`) + +--- + +## 6. TEMP-ADMIN-BYPASS (active in production as of 2026-05) + +While the `organization_roles` claim plumbing is being debugged, three +admin enforcement sites are intentionally disabled: + +| File | What was bypassed | +| -------------------------------------------- | ------------------------------------------------ | +| `api/middleware/auth.go` | `RequirePlatformAdmin` accepts any auth'd user | +| `web/src/features/admin/AdminGuard.tsx` | Route guard accepts any auth'd user | +| `web/src/components/Sidebar.tsx` | Admin link is shown to every auth'd user | + +A red banner is mounted on every authenticated page +(`web/src/features/admin/AdminBypassBanner.tsx`), gated by +`ADMIN_BYPASS_ACTIVE` in `bypass.ts`. Flipping that constant to +`false` hides the banner but does NOT restore the gates. + +### 6.1 Status + +**Resolved (see section 3.2).** The api's `JWTSession` middleware now +performs a Logto Management API lookup when the JWT lacks the +`organization_roles` claim. The bypass should be reverted as soon as +end-to-end verification confirms the new elevation works in +production (section 6.2). + +### 6.2 How to revert the bypass (when ready) + +In order: + +1. **Confirm the fix works** for at least one signed-in user: that + their `/me` returns `role: "platform_admin"` and a freshly-decoded + JWT contains `organization_roles: ["platform_admin"]`. +2. **Revert the backend gate**: in `api/middleware/auth.go` + `RequirePlatformAdmin`, uncomment the original role check and + remove the bypass line. Redeploy api. Sanity-check that you + still get through to admin endpoints; sign up a throwaway account + and verify they get 403. +3. **Revert the SPA route gate**: in `AdminGuard.tsx`, uncomment the + original `if (user?.role !== 'platform_admin')` block and remove + the bypass `if (!user)` block. Restore the `Navigate` import. +4. **Revert the sidebar visibility**: in `Sidebar.tsx`, restore the + `if (role === 'platform_admin')` guard around the + `groups.push(getAdminNavGroup(sportSlug))` call. Remove the + `void role` line. +5. **Hide the banner**: set `ADMIN_BYPASS_ACTIVE = false` in + `bypass.ts`. Optional: delete `AdminBypassBanner.tsx`, + `bypass.ts`, and the two import + render sites in `__root.tsx` + for a fully clean removal. +6. **Search for any remaining markers**: `git grep TEMP-ADMIN-BYPASS` + should return zero results after a complete revert. + +The original code is preserved verbatim in comments at each bypass +site, so each step is a mechanical uncomment + delete. + +--- + +## 7. Quick reference + +```sh +# Find every TEMP-ADMIN-BYPASS site +git grep TEMP-ADMIN-BYPASS + +# Re-run logto seed manually (CLI, optional -- the api auto-runs it) +docker compose -f docker-compose.yaml -f docker-compose.bootstrap.yaml \ + run --rm bootstrap + +# Trigger Coolify redeploy for the api+web stack +curl -sS -X POST \ + -H "Authorization: Bearer $COOLIFY_TOKEN" \ + "https://empower.relentnet.com/api/v1/deploy?uuid=y0gcg880gs0s0kowgks4k0sc&force=false" + +# Inspect the live SPA bundle for a code shape (admin link, etc.) +curl -sS https://courtcommand.app | grep -oE '/assets/index-[^"]+\.js' +``` diff --git a/docs/LOGTO_SETUP.md b/docs/LOGTO_SETUP.md new file mode 100644 index 0000000..36e49a4 --- /dev/null +++ b/docs/LOGTO_SETUP.md @@ -0,0 +1,307 @@ +# Logto Setup + +This guide configures the self-hosted Logto instance used by Court Command for +authentication, organization-scoped roles (sports), webhooks, and Logto-managed +machine-to-machine credentials (used by API keys and tournament staff accounts). + +**Audience:** the operator standing up Logto for the first time, or a developer +re-bootstrapping after a Logto database wipe. + +--- + +## Prerequisites + +- Logto is already deployed and reachable at: + - Core / OIDC endpoint: + - Admin UI: +- You can sign in to the Logto admin UI as a Logto-platform admin (this is a + Logto-internal account, not a Court Command user). +- You have access to the Coolify project for Court Command so you can paste + generated IDs/secrets into the API and web service environment variables. + +> **Secrets discipline.** Every value generated below (App secrets, signing +> keys) belongs in Coolify env vars, never in Git. The tracked +> `.env.example` documents only variable *names*, not values. + +--- + +## Step 1 — Create the SPA application (frontend) + +In → **Applications** → **Create application**: + +- **Type:** Single Page App (React) +- **Name:** `Court Command Web` +- **Redirect URIs:** + - `https://courtcommand.app/auth/callback` + - (optional, for future local dev) `http://localhost:5173/auth/callback` +- **Post sign-out redirect URIs:** + - `https://courtcommand.app` + - (optional) `http://localhost:5173` +- **CORS allowed origins:** + - `https://courtcommand.app` + - (optional) `http://localhost:5173` + +Click **Create**. After creation: + +| Capture | Goes to | +|---|---| +| **App ID** | Coolify (web service) → `VITE_LOGTO_APP_ID` | + +The web app does not have a client secret (SPAs are public clients). + +--- + +## Step 2 — Create the API resource + +In **API resources** → **Create API resource**: + +- **API name:** `Court Command API` +- **API identifier:** `https://api.courtcommand.app/api` + +Open the newly-created resource → **Permissions** tab → add the following 12 scopes: + +``` +read:profile +write:profile +read:tournaments +write:tournaments +read:matches +write:matches +read:registrations +write:registrations +read:overlay +write:overlay +read:admin +write:admin +``` + +These are the API-level scopes used for sport-agnostic permission checks (e.g. +"can write tournaments at all"). Per-sport role enforcement is layered on top +via organization roles (Step 4). + +No env-var change for this step; the identifier is already in +`LOGTO_API_RESOURCE` and `VITE_LOGTO_API_RESOURCE`. + +--- + +## Step 3 — Create the M2M app for the Management API + +In **Applications** → **Create application**: + +- **Type:** Machine-to-machine +- **Name:** `Court Command Backend` + +After creation, on the application detail page: + +1. **Roles** tab → assign the built-in role **`Logto Management API access`**. + This grants the backend permission to create users, modify org memberships, + create per-tenant M2M apps for the "API keys" feature, etc. +2. **Settings** tab → capture: + +| Capture | Goes to | +|---|---| +| **App ID** | Coolify (api service) → `LOGTO_MANAGEMENT_API_APP_ID` | +| **App Secret** | Coolify (api service) → `LOGTO_MANAGEMENT_API_APP_SECRET` | + +Verify the value of `LOGTO_MANAGEMENT_API_RESOURCE` in Coolify is +`https://default.logto.app/api`. + +> **Why that exact string?** Self-hosted Logto exposes its built-in Management +> API under a fixed audience identifier, `https://default.logto.app/api`, +> regardless of your custom domain. It is **not a real URL** — it is the +> resource indicator the OIDC token endpoint expects in the `resource` form +> parameter when minting Management API tokens. Using your public Logto URL +> (e.g. `https://logto.courtcommand.app/api`) returns +> `oidc.invalid_target: Invalid resource indicator`. This is distinct from +> `LOGTO_API_RESOURCE` (which IS your real public API URL — +> `https://api.courtcommand.app/api`). + +--- + +## Step 4 — Define the organization template (roles + scopes) + +In **Organizations** → **Organization template** tab. + +### 4a. Define organization scopes + +Under **Organization permissions** add: + +``` +manage_tournaments +manage_matches +manage_registrations +manage_users +read_all +``` + +### 4b. Define organization roles + +Under **Organization roles** add the following five roles. For each role, in +its detail page assign the listed organization scopes: + +| Role | Description | Organization scopes | +|---|---|---| +| `player` | Default role for users in this sport | `read_all` | +| `tournament_director` | Can manage tournaments in this sport | `read_all`, `manage_tournaments`, `manage_registrations` | +| `referee` | Can score matches in this sport | `read_all`, `manage_matches` | +| `scorekeeper` | Same as referee, alternate label for staff naming | `read_all`, `manage_matches` | +| `platform_admin` | Full platform access within this sport | all five | + +Role names are matched **literally** by the backend (case-sensitive). Do not +rename them without coordinating a code change. + +--- + +## Step 5 — Create the sport organizations + +In **Organizations** → **Organizations** tab → **Create organization**. + +Create two organizations: + +| Name | Description | +|---|---| +| `Pickleball` | Production sport on Court Command | +| `Demo Sport` | Test organization that exercises the multi-sport plumbing | + +After each creation, capture the **Organization ID** (`org_xxxxxxxxxxxxx`): + +| Capture | Goes to | +|---|---| +| Pickleball org ID | Coolify (api service) → `LOGTO_PICKLEBALL_ORG_ID` | +| Demo Sport org ID | Coolify (api service) → `LOGTO_DEMO_SPORT_ORG_ID` | + +The roles defined in Step 4 are automatically available in both orgs because +they live on the shared organization template. + +--- + +## Step 6 — Create the bootstrap platform admin user + +In **Users** → **Create user**: + +- **Email:** `daniel.f.velez@gmail.com` +- **Password:** set one (you'll use it to sign in for the first time) +- **Name:** `Daniel Velez` + +After creation, open the user → **Organizations** tab → **Add to organization**: + +- Add to **Pickleball** with role `platform_admin` +- Add to **Demo Sport** with role `platform_admin` + +Repeat for any additional bootstrap admins. + +> **No env-var change.** The user just needs to exist in Logto with the right +> org memberships before you sign in to the Court Command frontend for the +> first time. The backend will upsert a mirror row in `users` automatically on +> first authenticated request. + +--- + +## Step 7 — Register the webhook + +In **Webhooks** → **Create webhook**: + +- **Name:** `Court Command Backend` +- **Endpoint URL:** `https://api.courtcommand.app/api/v1/webhooks/logto` +- **Events:** check + - `User.Created` + - `User.Data.Updated` + - `User.Deleted` + +After creation, open the webhook detail page and capture: + +| Capture | Goes to | +|---|---| +| **Signing key** | Coolify (api service) → `LOGTO_WEBHOOK_SIGNING_KEY` | + +The backend validates the `logto-signature-sha-256` header on every webhook +delivery using HMAC-SHA256 of the raw request body keyed with this value. + +> The backend webhook handler is delivered in a later phase. Until then, +> Logto will deliver `User.Created` events that 404 — that's fine; the user +> still exists in Logto and the on-demand upsert path in `/auth/me` covers +> the gap. + +--- + +## Step 8 — Verify Coolify env vars + +Confirm both Coolify services have the full set of variables. Reference list +(see `.env.example` in the repo for the source of truth): + +### `api` service + +``` +LOGTO_ENDPOINT +LOGTO_API_RESOURCE +LOGTO_MANAGEMENT_API_APP_ID +LOGTO_MANAGEMENT_API_APP_SECRET +LOGTO_MANAGEMENT_API_RESOURCE +LOGTO_WEBHOOK_SIGNING_KEY +LOGTO_PICKLEBALL_ORG_ID +LOGTO_DEMO_SPORT_ORG_ID +``` + +### `web` service (build-time, baked into the Vite bundle) + +``` +VITE_LOGTO_ENDPOINT +VITE_LOGTO_APP_ID +VITE_LOGTO_API_RESOURCE +``` + +After updating, redeploy both services so the new variables take effect. + +--- + +## Step 9 — Smoke-test the Management API from the command line + +From a machine that can reach Logto (typically your local box): + +```bash +# Replace with the real values from Coolify +APP_ID=... +APP_SECRET=... +LOGTO_ENDPOINT=https://logto.courtcommand.app +RESOURCE=https://default.logto.app/api # fixed audience for self-hosted Mgmt API + +# 1. Get an M2M token +TOKEN=$(curl -s -X POST "$LOGTO_ENDPOINT/oidc/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -u "$APP_ID:$APP_SECRET" \ + -d "grant_type=client_credentials&resource=$RESOURCE&scope=all" \ + | jq -r .access_token) + +[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] && echo "✓ token issued" || { echo "✗ no token"; exit 1; } + +# 2. List users +curl -s "$LOGTO_ENDPOINT/api/users" -H "Authorization: Bearer $TOKEN" | jq '.[0:2]' + +# 3. List organizations (should include Pickleball + Demo Sport) +curl -s "$LOGTO_ENDPOINT/api/organizations" -H "Authorization: Bearer $TOKEN" | jq '.[].name' +``` + +Expected: token issued, users array contains the bootstrap admin, organizations +include both `Pickleball` and `Demo Sport`. If any of those fails, fix Logto +configuration before proceeding to backend implementation. + +--- + +## Re-seeding after a Logto wipe + +If the Logto Postgres database is recreated (e.g. volume rotation), Logto +loses **everything** documented above — apps, resources, scopes, org template, +orgs, users, webhooks. There is no automated re-seed today; re-run Steps 1–7 +manually and re-paste the regenerated IDs/secrets into Coolify. + +> **Future improvement:** `make seed-logto` could call the Management API to +> idempotently create everything from a YAML manifest. Tracked as a follow-up +> after the initial integration ships. + +--- + +## Reference + +- Spec: [`docs/superpowers/specs/2026-04-20-logto-integration-design.md`](superpowers/specs/2026-04-20-logto-integration-design.md) +- Plan: [`docs/superpowers/plans/2026-04-20-logto-integration.md`](superpowers/plans/2026-04-20-logto-integration.md) +- Logto docs: diff --git a/docs/PRODUCTION_DEPLOY.md b/docs/PRODUCTION_DEPLOY.md new file mode 100644 index 0000000..10d6668 --- /dev/null +++ b/docs/PRODUCTION_DEPLOY.md @@ -0,0 +1,298 @@ +# Production Deployment Runbook + +> One-shot launch guide for deploying `feature/logto-integration` to Coolify as a single Docker Compose stack (db + redis + logto + api + web + ghost). +> Run through this top-to-bottom on launch day. + +## Pre-flight + +- [ ] Coolify is up and you can reach its admin UI +- [ ] DNS records exist (all pointing at the Coolify host): + - `courtcommand.app` → web service + - `api.courtcommand.app` → api service + - `logto.courtcommand.app` → logto service (port 3001, OIDC core) + - `logto-admin.courtcommand.app` → logto service (port 3002, admin UI) + - `news.courtcommand.app` → ghost service +- [ ] You have access to `~/code/court-command-v2/_local-secrets/coolify-env.txt` on your laptop (the paste-ready Coolify env block) +- [ ] Resend domain `mail.courtcommand.app` is verified (DNS propagated) + +## Deploy sequence at a glance + +The deploy is a 3-step dance because the bootstrap container needs Logto running, but Logto needs first-run setup before bootstrap can connect: + +| Step | What you do | What you get | +|---|---|---| +| **1** | Paste Coolify env, deploy | db + redis + logto come up. api fail-fasts (Logto Mgmt vars empty). web builds with placeholder values. | +| **2** | Visit Logto admin UI, complete first-run wizard, create M2M app | App ID + Secret to paste into Coolify | +| **3** | Paste Mgmt API creds, redeploy | api now starts cleanly | +| **4** | SSH to Coolify host, run bootstrap container | Webhook signing key + SPA App ID + Pickleball org ID | +| **5** | Paste those into Coolify, redeploy | web bundle rebuilds with real Logto config; everything works | + +--- + +## Step 1 — Coolify env vars (configure BEFORE first deploy) + +Coolify deploys `docker-compose.yaml`. The compose file forwards env vars from Coolify's project-level env settings into each service. Set ALL of these in the Coolify project's "Environment Variables" tab. + +### Backend / api service + +| Variable | Production value | Source | +|---|---|---| +| `APP_ENV` | `production` | literal | +| `DATABASE_URL` | `postgres://courtcommand:@db:5432/courtcommand?sslmode=disable` | uses `db` service | +| `REDIS_URL` | `redis://redis:6379/0` | Coolify default | +| `POSTGRES_USER` | `courtcommand` | literal | +| `POSTGRES_PASSWORD` | (generate strong password) | random — write down | +| `POSTGRES_DB` | `courtcommand` | literal | +| `LOGTO_DB_USER` | `logto` | literal | +| `LOGTO_DB_PASSWORD` | (generate strong password) | random — write down | +| `LOGTO_DB_NAME` | `logto` | literal | +| `CORS_ALLOWED_ORIGINS` | `https://courtcommand.app` | literal (add news/staging if needed) | +| `LOGTO_ENDPOINT` | `https://logto.courtcommand.app` | from `_local-secrets/logto-prod-creds.env` | +| `LOGTO_API_RESOURCE` | `https://api.courtcommand.app/api` | from `_local-secrets/logto-prod-creds.env` | +| `LOGTO_MANAGEMENT_API_APP_ID` | (from Logto admin) | see Step 2 | +| `LOGTO_MANAGEMENT_API_APP_SECRET` | (from Logto admin) | see Step 2 | +| `LOGTO_MANAGEMENT_API_RESOURCE` | `https://default.logto.app/api` | Logto-internal fixed value | +| `LOGTO_WEBHOOK_SIGNING_KEY` | (filled by Step 3) | output of bootstrap container | +| `SMTP_HOST` | `smtp.resend.com` | Resend SMTP | +| `SMTP_PORT` | `465` | TLS | +| `SMTP_USER` | `resend` | literal — Resend's username | +| `SMTP_PASS` | `re_iCNtaEuU_...` | Resend API key | +| `EMAIL_FROM` | `noreply@mail.courtcommand.app` | from your verified Resend domain | +| `EMAIL_FROM_NAME` | `Court Command` | display name on emails | + +### Frontend / web service (build args — must be set BEFORE the web build) + +| Variable | Production value | +|---|---| +| `VITE_API_URL` | `https://api.courtcommand.app` | +| `VITE_LOGTO_ENDPOINT` | `https://logto.courtcommand.app` | +| `VITE_LOGTO_APP_ID` | (filled by Step 3) | +| `VITE_LOGTO_API_RESOURCE` | `https://api.courtcommand.app/api` | +| `VITE_AUTO_REDIRECT_SINGLE_SPORT` | `true` | +| `VITE_GOOGLE_MAPS_API_KEY` | (your Google Maps key, optional) | +| `VITE_WS_URL` | `wss://api.courtcommand.app/ws` | + +### Ghost (already configured) + +| Variable | Production value | +|---|---| +| `GHOST_URL` | `https://news.courtcommand.app` | + +> The variables marked **(filled by Step 3)** can stay blank for the first deploy — the api fail-fast will skip when they're empty AND `APP_ENV != production`. Set `APP_ENV=development` for the first push, then flip to `production` after Step 3 lands. + +--- + +## Step 2 — Logto first-run setup + Management API client + +Logto runs as a service in the same Docker Compose stack as the api/web/db/redis. After your first Coolify deploy, the `logto` service comes up with empty data — no admin user, no apps. You complete first-run setup via the admin UI, then create the M2M app the bootstrap container will use. + +1. Visit `https://logto-admin.courtcommand.app` +2. Logto's **first-run wizard** prompts you to create the operator account (the human who manages Logto itself — not your app users). Use a strong password; this is the gateway to all your tenant config. +3. Skip any introductory tour / survey +4. **Applications** → **Create application** → **Machine-to-machine** → name: `Court Command Backend Seeder` +5. Open the new app → **Roles** tab → click **Assign roles** → check `Logto Management API access` (built-in role) → save +6. **Settings** tab → copy **App ID** (looks like `rvvigkkmz5r009l9pm0ku`) and **App Secret** (the `re_...`-shaped one — clicking the eye icon reveals it once) +7. Paste both into Coolify env vars: + - `LOGTO_MANAGEMENT_API_APP_ID` + - `LOGTO_MANAGEMENT_API_APP_SECRET` +8. Coolify → redeploy the api service (it'll pick up the new vars and pass its production fail-fast) + +--- + +## Step 2.5 — Resend (production email delivery) + +Skipping this step is OK for a smoke deploy, but **without email configured, sign-up will be blocked** because Logto requires email verification when the sign-up identifier is email. The bootstrap admin is created by the seeder via the Mgmt API (bypassing the verification flow), so YOU can still sign in — but no second user can. + +1. Sign up at https://resend.com (free tier covers 3,000 emails/month, plenty for early launch) +2. **Domains** → **Add Domain** → `mail.courtcommand.app` (subdomain keeps the apex pristine) +3. Resend gives you 3-4 DNS records (SPF + DKIM + tracking). Add them to your DNS provider for `courtcommand.app`. Verification typically takes 5-30 min. +4. **API Keys** → **Create API Key** → name `Court Command production`, permission `Sending access`. Copy the `re_...` value. **The key is shown only once.** +5. Test the key with curl (the seeder will use SMTP, but a one-shot REST POST proves the key works): + + ```bash + curl -s -X POST 'https://api.resend.com/emails' \ + -H 'Authorization: Bearer re_YOUR_KEY' \ + -H 'Content-Type: application/json' \ + -d '{ + "from": "Court Command ", + "to": "your-account-email@example.com", + "subject": "Resend test", + "html": "

It works.

" + }' + ``` + + 200 with an `id` field = success. 403 with `validation_error` = domain not verified yet (wait for DNS). + +6. Paste the API key + `mail.courtcommand.app` into Coolify env vars (table in Step 1). + +> **Plain SMTP fallback.** If you'd rather use SendGrid, AWS SES, or Postmark, swap the SMTP host/port/user/pass in Coolify. The seeder treats them as opaque SMTP credentials — only the sender domain needs to match `EMAIL_FROM`. + +--- + +## Step 3 — Run prod-bootstrap (provisions Logto + syncs DB) + +The bootstrap is a one-shot Docker container that runs in the same network as the prod stack, so **the DB never has to be exposed to the public internet**. The compose file is at `docker-compose.bootstrap.yaml`. + +### Prepare `.env.prod` on the Coolify host + +SSH to your Coolify VM, cd into the cloned repo (Coolify keeps a copy under `/data/coolify/applications//source` or similar — see Coolify dashboard for the exact path). + +Create a `.env.prod` file there (gitignored, never committed): + +```bash +# Tells the seeder to skip Demo Sport +APP_ENV=production + +# Application database -- internal hostname `db` works because the +# bootstrap service runs on the same Compose network as the api/db services +DATABASE_URL=postgres://courtcommand:@db:5432/courtcommand?sslmode=disable + +# Logto +LOGTO_ENDPOINT=https://logto.courtcommand.app +LOGTO_API_RESOURCE=https://api.courtcommand.app/api +LOGTO_MANAGEMENT_API_APP_ID= +LOGTO_MANAGEMENT_API_APP_SECRET= +LOGTO_MANAGEMENT_API_RESOURCE=https://default.logto.app/api + +LOGTO_SPA_REDIRECT_URI=https://courtcommand.app/auth/callback +LOGTO_WEBHOOK_URL=https://api.courtcommand.app/api/v1/webhooks/logto + +LOGTO_BOOTSTRAP_EMAIL=daniel.f.velez@gmail.com +LOGTO_BOOTSTRAP_PASSWORD= +LOGTO_BOOTSTRAP_NAME="Daniel Velez" + +# Resend SMTP (from Step 2.5) +SMTP_HOST=smtp.resend.com +SMTP_PORT=465 +SMTP_USER=resend +SMTP_PASS=re_YOUR_KEY +EMAIL_FROM=noreply@mail.courtcommand.app +EMAIL_FROM_NAME=Court Command +EMAIL_VERIFY_ON_SIGNUP=true +``` + +### Run the bootstrap container + +```bash +# On the Coolify host, in the repo directory: +set -a && . ./.env.prod && set +a + +docker compose \ + -f docker-compose.yaml \ + -f docker-compose.bootstrap.yaml \ + run --rm bootstrap +``` + +The container builds the api image (sharing layer cache with the existing prod build), runs the seeder once, exits 0 on success. + +The seeder provisions: +- 12 API resource scopes +- SPA app `Court Command Web` +- Pickleball organization +- 5 org scopes + 5 org roles + scope→role bindings +- Bootstrap admin user with `platform_admin` org role +- User-level role `Court Command API (all scopes)` granting all 12 API scopes to the bootstrap admin +- SMTP email connector (Resend) with the four canonical email templates +- Sign-in experience: email + username identifiers, magic-link sign-in enabled, sign-up email verification enabled +- Webhook → `https://api.courtcommand.app/api/v1/webhooks/logto` +- `sports.logto_org_id` row in the prod app DB synced to match the new Pickleball org + +The summary block at the end prints values for: +- `LOGTO_PICKLEBALL_ORG_ID=...` +- `LOGTO_WEBHOOK_SIGNING_KEY=...` +- `VITE_LOGTO_APP_ID=...` + +### Paste into Coolify + +Take the printed values and update the Coolify env tab: +- `LOGTO_WEBHOOK_SIGNING_KEY` (api service) +- `VITE_LOGTO_APP_ID` (web service) + +--- + +## Step 4 — Trigger Coolify rebuild + +In Coolify: +1. Click **Redeploy** on the Docker Compose resource + +This rebuild bakes in the bootstrap-output values (`VITE_LOGTO_APP_ID`, `LOGTO_WEBHOOK_SIGNING_KEY`) — without them, the api fail-fasts in production mode and the web bundle has placeholder values that throw on AuthProvider init. + +The api runs migrations on startup. `/api/v1/health` should return `{database:ok, redis:ok, status:ok}` once it's up. + +--- + +## Step 4.5 — Upload Court Command theme to Ghost + +Ghost ships with a default Casper theme on first install. To get the Court Command branding (sidebar, header, category tabs): + +1. On your laptop: + ```bash + cd ~/code/court-command-v2/court-command + make ghost-theme + ``` + This packages `ghost-theme/` into `ghost-theme/cc-ghost-theme.zip` (gitignored). + +2. Visit `https://news.courtcommand.app/ghost` and complete Ghost's first-run setup (create owner account, name the site, etc.). Use Resend SMTP for the owner email since the SMTP env vars are already in the compose stack. + +3. **Settings → Design → Change theme → Upload theme** → select `cc-ghost-theme.zip` → **Activate**. + +The theme persists in the `ghost_content` Docker volume across container restarts. Re-uploading is idempotent. + +> If Ghost's first-run setup emails (owner password reset, member welcome) don't arrive, check Resend's **Logs** tab. Ghost SMTP is configured via the same env vars Logto uses (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`). + +--- + +## Step 5 — First sign-in smoke test + +1. Visit `https://courtcommand.app/` +2. PublicLanding renders (hero + tournaments/leagues/venues directories — empty until you create some) +3. Click **Sign In to Get Started** +4. Logto sign-in form appears at `https://logto.courtcommand.app/sign-in` +5. Sign in with `daniel.f.velez@gmail.com` and the bootstrap password (or click **Sign in with email code** for the magic-link flow — Resend should deliver the code in a few seconds) +6. Browser redirects to `https://courtcommand.app/auth/callback?code=...` +7. After token exchange, lands on `https://courtcommand.app/` (PublicLanding inside authenticated shell) +8. Click **Dashboard** in the sidebar → `/pickleball/dashboard` renders +9. Click **Profile** → `/pickleball/profile` renders the form + +If anything 401s, check the api logs in Coolify and verify each Logto env var matches between Logto admin and Coolify. + +If magic-link emails don't arrive: check Resend dashboard's **Logs** tab for delivery failures (most common: domain not verified, sender mismatch, gmail spam folder). + +--- + +## Step 6 — Post-launch hygiene + +- [ ] Rotate `LOGTO_MANAGEMENT_API_APP_SECRET` if it was ever pasted into chat +- [ ] Disable the temporary 5432 exposure on the db service (if used for prod-bootstrap) +- [ ] Take a backup: `make backup-full` +- [ ] Push the `feature/logto-integration` branch to GitHub: + ```bash + git push origin feature/logto-integration + ``` +- [ ] Open the PR on GitHub (already exists at #4 — just push, the new commits land in the existing PR) +- [ ] Once verified working, merge to `main` +- [ ] Update `_local-secrets/logto-prod-creds.env` on your laptop with the final values + +--- + +## Known issues (acceptable for launch) + +These are documented in `docs/FEATURES.md §21`: + +- **Impersonation under JWT** — broken, restored in Phase 4 (Logto-native via OAuth Token Exchange) +- **Cross-sport data leak** — RequireSportMatchesJWT not chained yet; not exploitable without the user manually editing URLs and a multi-sport account +- **Role mapping incomplete** — TD/ref/scorekeeper users created via Logto land as `users.role='player'` until Phase 4 adds the role mapping middleware. Workaround: manual SQL update for now. +- **Ref console scope reduced** — Phase 4 redesign needed +- **Live stream embed** — needs UI for `stream_url` +- **VAIR API integration** — Phase 4 + +None of these block initial launch with Pickleball as a single-tenant org and the bootstrap admin running tournaments. + +--- + +## Rollback + +If the deploy is broken and you need to revert: +1. Coolify → api service → Deployments tab → pick the previous green deployment → **Redeploy** +2. Coolify → web service → same +3. The DB schema is **forward-compatible** with the cookie-only `main` branch (Phase 2 was additive-only) — old code will tolerate the new columns. So a Coolify revert is a single-click full rollback. diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md new file mode 100644 index 0000000..12ecf4e --- /dev/null +++ b/docs/SMOKE_TEST.md @@ -0,0 +1,487 @@ +# Court Command — Local Smoke Test Checklist + +> Walk through this once on a fresh sign-in. Mark each item PASS / FAIL / NOTES. +> Anything FAIL → tell the AI: page URL, what you saw, what you expected, console error if visible (DevTools → Console). + +## Prerequisites + +- [ ] Stack up: `docker compose -f docker-compose.dev.yml ps` shows 3 healthy containers +- [ ] Backend: `curl http://localhost:8080/api/v1/health` returns `status: ok` +- [ ] Frontend: http://localhost:5173/ responds 200 +- [ ] Bootstrap admin: `admin@courtcommand.local` / `TestPass123!` +- [ ] Hard-reload browser to dump service-worker cache: **Cmd-Shift-R** (Mac) / **Ctrl-Shift-R** (Linux/Win) + +--- + +## 1. Public / Anonymous Experience (do these signed OUT first) + +If currently signed in: click **Log out** in sidebar. + +| # | URL | What to verify | +|---|---|---| +| 1.1 | `/` | Public landing — hero with "Sign In to Get Started" button + news widget + tournaments/leagues/venues directories | +# This is good, but its never accessible once signed in. Shouldnt we allow it? +| 1.2 | Top bar visible? Bottom tabs (Home / Events / Live / News / More) at the bottom on mobile width | | +#Only when signed out +| 1.3 | `/public/tournaments` | Lists 3 seeded tournaments (Spring Slam, Summer Open, Autumn Classic) | +#good to go +| 1.4 | Click into one tournament | Detail page renders — overview, divisions visible | +#good to go +| 1.5 | `/public/leagues` | Lists 2 seeded leagues | +#good to go +| 1.6 | Click into one league | Detail with seasons visible | +#good to go +| 1.7 | `/public/venues` | Lists 2 venues (Pickleton Community Center, Lone Star Pickleball) | +#good to go +| 1.8 | Click into one venue | Detail page — info, courts, map (or "Maps unavailable" without API key) | +#good to go +| 1.9 | `/public/live` | "No live matches" message OR list of currently-live matches | +#the page is good but there is no easy way to get to it while logged in +| 1.10 | `/public/events` | Combined feed of tournaments + leagues | +#the page is good but there is no easy way to get to it while logged in +| 1.11 | Press **Cmd-K** (or click Search button) | Search modal opens | +#search modal opens +| 1.12 | Type "Spring" in search | Results show Spring Slam tournament | +#this does not work, and it seems like it is trying to live search which once it tries it blancks out the page completely +| 1.13 | Theme toggle (top right of sidebar/menu) | Light / dark / system cycle works | +#themes work + +--- + +## 2. Authentication + +| # | Action | Verify | +|---|---|---| +| 2.1 | Click "Sign In to Get Started" in PublicHero | Browser redirects to http://localhost:3001/sign-in | +#good to go +| 2.2 | Enter `admin@courtcommand.local` | Field accepts email | +#good to go +| 2.3 | Click Continue (if it appears) | Goes to password step | +#good to go +| 2.4 | Enter `TestPass123!`, click Sign In | Redirects to http://localhost:5173/auth/callback briefly, then to `/pickleball/dashboard` | +#good to go +| 2.5 | Dashboard renders with "Welcome back, Local" | | +#it does render Welcome back, Local but it is far to tiny. and font color is too subdued. +| 2.6 | Sidebar visible on left, fully expanded or collapsed | | +#both are visible +| 2.7 | DevTools → Network: any request to `/api/v1/...` should have `Authorization: Bearer eyJ...` and `X-Sport: pickleball` headers | | +# not sure where to test this + +--- + +## 3. Sidebar Navigation (the critical regression test from Sidebar fix) + +Each click should land at the correct URL — NOT bounce to dashboard. + +| # | Click | Expected URL | Verify | +|---|---|---|---| +| 3.1 | Home | `/` (auto-redirects authenticated single-sport user back to dashboard — this is intentional behavior) | | +#works as intended but wondering if home should have its own page that lets users hav see the logged out home page, that also lets use make announcements. +| 3.2 | Dashboard | `/pickleball/dashboard` | | +#good to go +| 3.3 | My Assets | `/pickleball/manage` | Lists tournaments/leagues/orgs the admin manages | +#good to go +| 3.4 | Leagues | `/pickleball/leagues` | 2 leagues visible | +#good to go +| 3.5 | Tournaments | `/pickleball/tournaments` | 3 tournaments visible | +#good to go +| 3.6 | Venues & Courts | `/pickleball/venues` | 2 venues visible | +#good to go +| 3.7 | Players | `/pickleball/players` | 24 players (1 admin + 17 unclaimed + 6 staff) | +#good to go +| 3.8 | Teams | `/pickleball/teams` | 8 teams visible | +#good to go +| 3.9 | Organizations | `/pickleball/organizations` | 3 orgs visible | +#good to go +| 3.10 | Ref Console | `/pickleball/ref` | List of courts/matches for refs | +#good to go +| 3.11 | Scorekeeper | `/pickleball/scorekeeper` | Scorekeeper home | +#good to go +| 3.12 | Quick Match | `/pickleball/quick-match` | Quick match list | +#good to go but no matches to show +| 3.13 | Overlay | `/overlay/` (note trailing slash) | Renders OverlayLanding (not redirect to dashboard) | +#good to go +| 3.14 | Admin | `/pickleball/admin` | Admin landing page | +#good to go + +--- + +## 4. Dashboard (`/pickleball/dashboard`) + +| # | Verify | +|---|---| +| 4.1 | "Welcome back, Local" header | +#It is there, could be visually more appealing. +| 4.2 | Stats summary cards (W/L, recent activity) — even if zero | +#it shows +| 4.3 | Active Registrations section (likely empty for admin) | +#it is there +| 4.4 | Upcoming Matches section | +#it is there +| 4.5 | My Teams section — admin not on any team, so likely empty | +#it is there +| 4.6 | Recent Results section | +#it is there +| 4.7 | Dashboard Announcements section (5 seeded announcements) | +#it is there +| 4.8 | No console errors | +#none that i see so far. + +--- + +## 5. Profile (`/pickleball/profile`) + +| # | Verify | +|---|---| +| 5.1 | "Edit profile" header | +# The header is there. +| 5.2 | All 8 sections visible: Contact, Pickleball Identity, Equipment, Demographics, Address, Emergency Contact, Medical, Privacy | +#all there +| 5.3 | Fill phone with a number (e.g., `555-9876`), click Save | +#was able to write and then clicked saved but then when the page reloaded it reloaded with the old number. +| 5.4 | Toast appears: "Profile saved" | +#this does appear +| 5.5 | Reload page (F5) | +#once I reloaded with F5 i now see the new number we saved. +| 5.6 | Phone field shows the value you saved | +#yes +| 5.7 | Try the gender dropdown (Not specified / Male / Female / Non-binary / Prefer not to say) — saves correctly | +#yes i see these, and i can save but it seems no values show after initial save button is clicked until i f5 and refresh, users could get confused. +| 5.8 | Try date of birth picker — saves correctly | +#worked +| 5.9 | Try filling Address line 1 + line 2 + city + state + country + postal code — saves and persists | +#works but shouldnt this be the same address style as all others with googles api. +| 5.10 | Privacy toggle ("Hide my profile from public directories") — saves | +#it saves and reflects after F5 + +--- + +## 6. Tournaments (`/pickleball/tournaments`) + +| # | Verify | +|---|---| +| 6.1 | List shows 3 tournaments with dates | +#See all 3 +| 6.2 | Click "Spring Slam" → detail page | +#Summer Slam 2026 +| 6.3 | Tabs visible: Overview, Divisions, Courts, Staff, Settings | +#I see these and registration and announcements +| 6.4 | Divisions tab shows seeded divisions | +#Yes +| 6.5 | Click a division → division detail with registrations + bracket | +#I see this +| 6.6 | Try Settings tab — name, description, dates editable | +#I see these things +| 6.7 | Try clicking "Create Tournament" button | +#Works +| 6.8 | Form accepts a name, select sport=pickleball (single sport), submit | +#works +| 6.9 | New tournament appears in the list | +#works +| 6.10 | Try cloning a tournament (look for Clone button on detail) | +#work + +--- + +## 7. Leagues (`/pickleball/leagues`) + +| # | Verify | +|---|---| +| 7.1 | List shows 2 leagues | +#gtg +| 7.2 | Click into a league → seasons visible | +#gtg +| 7.3 | Click into a season → standings, registrations, division templates tabs | +#gtg +| 7.4 | Try creating a league | +#gtg +| 7.5 | League announcements feed | +#gtg + +--- + +## 8. Registry (Players, Teams, Orgs, Venues, Courts) + +### Players +| # | Verify | +|---|---| +| 8.1 | Players list shows 24 entries | +#This does work but DUPR is showing in the List, DUPR is fine but VAIR will be our prefered partner, while we are agnostic VAIR has expressed love for our product, and a want to work with us. +| 8.2 | Click on Daniel Velez → detail page | +#I do see the details page +| 8.3 | Search/filter works | +#Search does work. + +### Teams +| # | Verify | +|---|---| +| 8.4 | Teams list shows 8 teams | +#gtg +| 8.5 | Click into a team → roster visible | +#gtg +| 8.6 | Try creating a team (Create button) | +#gtg but the form does not show when it is completed. I need some kinda confirmation besides the form just going blank. + +### Organizations +| # | Verify | +|---|---| +| 8.7 | Orgs list shows 3 | +#gtg +| 8.8 | Click into one → members, blocks tabs | +#gtg +| 8.9 | Try creating an org | +#gtg + +### Venues +| # | Verify | +|---|---| +| 8.10 | 2 venues visible | +##gtg +| 8.11 | Click into Pickleton Community Center → courts list (4 courts) | +##gtg +| 8.12 | Click into Lone Star Pickleball → courts list (4 courts) | +##gtg +| 8.13 | Map renders OR shows graceful "Maps unavailable" message | +#map unavailable is what I get but this is probably because of the API key missing. + +### Courts +| # | Verify | +|---|---| +| 8.14 | Courts page lists 8 courts (court-1 through outdoor-court) | +#gtg +| 8.15 | Click into "Center Court" (slug `center-court`) → detail with stream embed area, queue, recent results | +#all details are there except, it shows that its live but I dont see an embed of the video. Not sure why not. +--- + +## 9. Match Operations + +| # | Verify | +|---|---| +| 9.1 | Look for matches under a tournament division | +#gtg but its under courts, for now this is good. Might want both court and matches in the future to be able to see them both but for now this will do. +| 9.2 | Click into a match → detail page (hero, info panel, events timeline) | +#gtg +| 9.3 | Public match scoreboard URL like `/pickleball/matches//scoreboard` renders fullscreen, no shell | +#scoreboard comes up with direct URL, but there is no way via navigation to get to the scoreboard url, maybe this can live in the overlay area or the matches area or both. +| 9.4 | Match-series detail if any seeded | +#gtg + +--- + +## 10. Quick Match (`/pickleball/quick-match`) + +| # | Verify | +|---|---| +| 10.1 | List shows 2 seeded quick matches | +#there is not quick matches seeded. +| 10.2 | Click "New Quick Match" | +#I can create and it does show in the list after. +| 10.3 | Form: pick court, teams (or create on the fly), scoring preset → start | +#gtg +| 10.4 | New quick match appears | +#gtg +--- + +## 11. Scoring Consoles + +### Referee (`/pickleball/ref`) + +| # | Verify | +|---|---| +| 11.1 | Court grid / list of assigned courts | +#gtg +| 11.2 | Click into a court → live scoring UI | +#gtg +| 11.3 | Scoring buttons (Side Out, Point) work — score increments | +##gtg +| 11.4 | Serve indicator updates | +#gtg +| 11.5 | Game history bar shows games so far | +#this does not seem to appear under the ref area. It seems like the ref area has been stripped down alot from what it used to be. it looks more like what the scorekeeper interface should look like. Ref should have all the verball calls he can make on the tablet interface as well. and the log. +| 11.6 | Keyboard shortcuts (try Space for point, S for side-out — exact bindings in app) | +#S works but space for bindings does not. +| 11.7 | Disconnect banner appears if you stop the backend (optional advanced test) | +#This does show. + +### Scorekeeper (`/pickleball/scorekeeper`) + +| # | Verify | +|---|---| +| 11.8 | List of matches | +#gtg +| 11.9 | Click in → read-only scoreboard + events timeline | +#This scoreboard works, exactly the way it should work, point, side out, undo, timeout all work and nothing else is visible. + +--- + +## 12. Brackets, Court Queue, Standings + +| # | Verify | +|---|---| +| 12.1 | Tournament division → Bracket tab renders | +#gtg +| 12.2 | Standings show under league season — even if zeros | +#gtg +| 12.3 | Court queue visible per court | +#gtg + +--- + +## 13. Overlay System + +### Public overlay (no auth) +| # | URL | Verify | +|---|---|---| +| 13.1 | `/overlay/court/center-court` | Transparent background scoreboard renders. No app shell. Works for OBS browser source | +#gtg +| 13.2 | `/overlay/demo/` | Demo overlay with mock data — try `default` theme | +#gtg + +### Overlay control panel (admin) +| # | URL | Verify | +|---|---|---| +| 13.3 | `/overlay/court/center-court/settings` | **Settings page renders, doesn't crash.** This was the bug we just fixed. | +#This is perfect, however I dont like how the saving dialogue currently pops up right under the tabs as it pushes the whole page and then goes back after it disapears again. Can we make it a little popup green modal like you already do. +| 13.4 | Tabs: Elements / Theme / Source / Triggers / Overrides / OBS URL all clickable | +#gtg +| 13.5 | Elements tab — toggle scoreboard visibility, save, see preview update | +#gtg +| 13.6 | Theme tab — pick a theme, preview changes | +#gtg +| 13.7 | OBS URL tab — generate a token, URL appears, can copy | +#gtg +| 13.8 | Layout mode toggle (Auto / Top / Side) works | +#gtg + +### Producer Monitor + Setup Wizard +| # | URL | Verify | +|---|---|---| +| 13.9 | `/overlay/monitor` | Producer monitor UI | +#gtg +| 13.10 | `/overlay/setup` | Setup wizard | +#gtg +| 13.11 | `/overlay/source-profiles` | Source profile list | +#gtg + +--- + +## 14. TV / Kiosk Displays + +| # | URL | Verify | +|---|---|---| +| 14.1 | `/tv/courts/center-court` | Fullscreen court display, no shell | +#this works but need an easy way to navigate to it. Maybe under the courts page? +| 14.2 | `/tv/tournaments/` | Fullscreen tournament display, no shell | +#this works but it is not themed properly will need a dark background. We will implemement a way to add a sponsor to this and theme it later but for now I need it to have a dark background go with the court command default for dark mode. + +--- + +## 15. Admin Platform (platform_admin only) + +| # | URL | Verify | +|---|---|---| +| 15.1 | `/pickleball/admin` | Admin landing | +#gtg +| 15.2 | `/pickleball/admin/users` | User search — 24 users | +#gtg +| 15.3 | Click into a user → detail page | +#gtg +| 15.4 | Try changing role / status (don't break the admin user!) | +#gtg +| 15.5 | `/pickleball/admin/activity` | Activity log entries | +#gtg +| 15.6 | `/pickleball/admin/settings` | Site settings — Ghost CMS toggle, Maps API key | +#gtg +| 15.7 | `/pickleball/admin/uploads` | Upload browser | +#gtg +| 15.8 | `/pickleball/admin/ads` | Ad manager — 3 seeded ad configs | +#gtg +| 15.9 | `/pickleball/admin/api-keys` | 2 seeded API keys | +#only see one here, does this populate from logto and is it authenticated via logto? +| 15.10 | `/pickleball/admin/venues` | Venue approval list | +#gtg +| 15.11 | **Impersonation: do NOT click — this is documented as ⚠️ broken under JWT** (see FEATURES.md §2 / §20) | +#the button for this is not there but we can worry about this whent we implement which should be the next thing we implement. after testing. Then we need to implement the VAIR rating api, I have documentation. I also am thinking of allowing a SSO with VAIR and eventually allowing users to do directly to their VAIR dashboard from our their CC dashboard. + +--- + +## 16. Cross-Cutting / UX + +| # | Verify | +|---|---| +| 16.1 | Sidebar collapse / expand button works (left edge) | +#This Works +| 16.2 | Sidebar collapsed state persists across reloads (localStorage) | +#this works +| 16.3 | Mobile width: sidebar disappears, top bar + bottom tabs appear (resize to <768px) | +#Sidebar stays and bottom tabs are not currently appearing as they should. +| 16.4 | Toast notifications work (any successful save) | +#this does work except it should be the way our saves under overlay elements popup which i commented above. +| 16.5 | Error toasts work (try saving an invalid form) | +#gtg +| 16.6 | Modal dismiss (Esc key, overlay click) | +#gtg +| 16.7 | Confirm dialog appears before destructive actions (e.g., delete a team) | +#gtg +| 16.8 | Offline banner: stop backend → banner appears within ~30s | +#gtg +| 16.9 | Switch sport link in sidebar → goes to `/` (sport picker / public landing) | +#gtg +| 16.10 | Sign out → returns to `/`, no auth on next request | +# i can logout it seem but it goes to this. - http://localhost:3001/oidc/session/end?client_id=va4v6kxbe5azd501e9oqp&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173%2F + +--- + +## 17. Edge Cases & Known Issues + +| # | Test | Expected behavior | +|---|---|---| +| 17.1 | Manually edit URL to `/foo/dashboard` (unknown sport) | Bounces to `/` (sport picker / public landing) | +#gtg +| 17.2 | Manually edit URL to `/demo_sport/dashboard` | **Currently** would bounce to `/` because demo_sport is_active=false. Whatever happens, no crash. | +#gtg +| 17.3 | Hard-reload while on a sport-scoped page | Page reloads cleanly, JWT still valid, content renders | +#gtg +| 17.4 | Open two tabs, log out in one | The other tab eventually redirects to sign-in (on next API call) | +#this works but if I click on something on the other window it still lets me go to those other urls just errors out instead of redirecting me to some kind of public page. +| 17.5 | Manually edit URL to `/pickleball/` | 404 "Page not found" | +#gtg +| 17.6 | URL `/auth/callback` without query params (e.g., from history) | Either re-authenticates or goes to `/` cleanly | +it just stays in a blank page with that url, however if I click sign in it auto signs back in without asking me credentials, not sure if this is by design. + +--- + +## 18. Performance / Quick Visual Check + +| # | Verify | +|---|---| +| 18.1 | Initial load < 3s on local | +#gtg this is an extremly fast site +| 18.2 | Sidebar feels instant on click | +#gtg +| 18.3 | No flash of unstyled content (FOUC) | +#gtg +| 18.4 | No layout shift when sidebar collapse | +#gtg here but the only layout shift on the site is in the overlay area i spoke of earlier and anytime the scorekeepe/ref area says disconnecting or connected and disapears again. +| 18.5 | DevTools → Network: no 500-class errors during normal browsing | +#gtg +| 18.6 | DevTools → Console: no red errors during normal browsing (yellow warnings are usually OK) | +#gtg + +--- + +## After You're Done + +For every FAIL, paste to the AI: + +1. **Section/item number** (e.g., 13.5) +2. **URL** you were on +3. **What you did** (clicked X, filled Y, etc.) +4. **What you saw** (text on screen, screenshot if helpful) +5. **What you expected** +6. **Console errors** if visible (DevTools → Console → red lines) +7. **Network errors** if visible (DevTools → Network → 4xx/5xx rows) + +The AI will mark each issue in `docs/FEATURES.md` and queue the fix. + +When everything passes, you've validated the full Phase 3+3.5+3.6+3.7 surface and we can decide whether to proceed with Phase 6 cleanup, the rewrite (C2 separate branch), or Phase 4 features. diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..5981f47 --- /dev/null +++ b/docs/STATUS.md @@ -0,0 +1,107 @@ +# Court Command — Status & Handoff + +_Last updated: 2026-06-14. Work branch: **`feature/logto-integration`** (the real product tip — `main` is a stale pre-Logto snapshot, do not use it)._ + +## TL;DR + +The local dev stack (Postgres + Redis + Logto + Go API + React web) was stood up and the +beta wrap-up is largely done. The annotated [SMOKE_TEST.md](SMOKE_TEST.md) punchlist, the +security hardening, the ref console, impersonation, and a new **public spectator drill-in** +feature are all merged (**15 PRs, #5–#19**). What remains before beta: an optional dedicated +public court page, the **VAIR** integration (blocked on vendor docs), the **Coolify deploy**, +and a README refresh. + +## Done (merged on `feature/logto-integration`) + +| PR | What | +|----|------| +| #5 | Beta punchlist + PR plan | +| #6 | fix(dev): postgres-init script must be POSIX sh, not bash (Logto DB now created) | +| #7 | fix(seed): quick matches + API keys owned by admin so they list | +| #8 | fix(layout): gate public bottom tabs on mobile for anon | +| #9 | fix(courts): block "stream live" when no stream URL | +| #10 | fix(players): VAIR shown before DUPR on detail + profile | +| #11 | fix(seed): username sign-up identifier when email verify unavailable (dev) | +| #12 | fix(auth): clean post-logout landing (no raw Logto end-session page) | +| #13 | **fix(security)**: remove admin bypass, complete org-role mapping, chain sport-JWT, lock WS origin | +| #14 | feat(ref): verbal calls + event log on the referee console (+ migration 00043) | +| #15 | feat(admin): restore impersonation via Logto token exchange | +| #16 | fix(impersonation): emit `act` claim via Logto JWT customizer (completes #15) | +| #17 | feat(public): read-only division detail + bracket + standings + matches endpoints | +| #18 | feat(public): spectator division page (bracket/standings/matches) + wire division cards | +| #19 | feat(public): anon-safe match page + court card drill-in, honest affordances | + +Triage also confirmed **6 smoke items were already fixed** in earlier batches (global search, +profile save, team-create feedback, overlay-save jank, dashboard header, TV dark theme). + +### Bugs found & fixed while standing up (not on the original list) +- **#6** postgres-init used a `bash` shebang; `postgres:17-alpine` has no bash, so the `logto` + database was never created and Logto couldn't start. +- **#11** the Logto seeder crashed setting the sign-in experience with no SMTP (Logto rejects an + email sign-up identifier without verification) — fall back to a username sign-up identifier. +- **#16** the impersonation token exchange produced no `act` claim on vanilla Logto; the seeder + now installs an access-token JWT customizer that maps the subject-token context to `act`. + +## What's left + +1. **(Decision pending) Dedicated public court page** — its own URL showing a court's live match + + recent results + queue + stream. ~2 PRs (one backend single-court public endpoint + one + page). **Current behavior:** courts link directly to their live/on-deck match, and quiet + courts are honest static cards. Decide whether the standalone court page is worth it. +2. **VAIR rating API sync** — backend client + sync job. **BLOCKED:** needs the VAIR API docs. + Drop them in `docs/vendor/vair/`. +3. **VAIR SSO + dashboard link** — Logto social/enterprise connector + "Sign in with VAIR" + + jump-to-VAIR-dashboard. **BLOCKED:** needs VAIR OAuth credentials + docs. Depends on (2). +4. **Coolify deploy → beta** — prod uses `docker-compose.yaml` (separate app DB + Logto DB + + Ghost) against `logto.courtcommand.app`; see [LOGTO_SETUP.md](LOGTO_SETUP.md). The seeder now + handles the prod JWT customizer + token-exchange. Run the prod seed once, paste outputs into + Coolify env, redeploy `web` (VITE_* are build-time). +5. **README refresh** — `README.md` is stale (claims the frontend isn't started; references + `backend/` instead of `api/`). +6. **Browser eyeball (low-risk confirmations)** — verified at code + headless level, but worth a + click-through: the new public division/match/court drill-in, the ref console verbal-calls + + event-log, the post-logout landing, the impersonation banner/stop flow, and the 6 + already-fixed smoke items. + +## Resuming on another machine (Mac) + +`.env` and the local Logto tenant are **machine-local and gitignored — they do NOT transfer.** +The Mac provisions its own fresh Logto. Full walkthrough: [LOCAL_DEV.md](LOCAL_DEV.md). Short version: + +```sh +git fetch origin && git checkout feature/logto-integration && git pull + +cp .env.example .env +make dev-up # Docker: Postgres + Redis + Logto + +# In the Logto admin UI (http://localhost:3002): +# - create the Logto admin account (first-run wizard) +# - Applications -> Create -> Machine-to-machine "Court Command Backend" +# -> Roles -> assign "Logto Management API access" +# - copy App ID + App Secret into .env (LOGTO_MANAGEMENT_API_APP_ID / _SECRET) + +make logto-seed # prints VITE_LOGTO_APP_ID, LOGTO_WEBHOOK_SIGNING_KEY, org IDs +# -> paste those into .env, AND create web/.env with the VITE_* values (see "gotchas") + +make dev # Go API at :8080 (runs migrations + auto-seeds Logto on boot) +make dev-frontend # Vite at :5173 + +# Sign in once at http://localhost:5173 as admin@courtcommand.local / TestPass123! +# (mirrors the bootstrap admin into the local users table — required before domain seed) +make seed # domain fixtures (tournaments, venues, matches, etc.) +``` + +The two seeder/infra bugs above are fixed, so this first-run now completes cleanly on a fresh box. + +### Local-dev gotchas learned this session +- **`web/.env` is required** — Vite reads env from `web/`, not the repo root. It needs + `VITE_API_URL`, `VITE_LOGTO_ENDPOINT`, `VITE_LOGTO_APP_ID`, `VITE_LOGTO_API_RESOURCE` + (mirror the repo-root `.env` Logto values). See `web/.env.example`. +- **Domain seed needs a Logto-linked admin first** — `make seed` aborts with "No Logto-linked + admin user found" until you've signed in once via the SPA (which mirrors the admin). +- **Token exchange** is enabled by default for existing SPA apps on Logto 1.22; the seeder + installs the access-token JWT customizer that emits the impersonation `act` claim. + +## Pointers +- Annotated smoke run: [SMOKE_TEST.md](SMOKE_TEST.md) · Feature inventory + known-broken: [FEATURES.md](FEATURES.md) +- PR plan / history: [BETA_PUNCHLIST.md](BETA_PUNCHLIST.md) · Prod Logto: [LOGTO_SETUP.md](LOGTO_SETUP.md) diff --git a/docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md b/docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md new file mode 100644 index 0000000..e16fada --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md @@ -0,0 +1,581 @@ +# Logto Phase 2 — Additive Schema Migration + +> Expanded from the Phase 2 outline of +> `docs/superpowers/plans/2026-04-20-logto-integration.md` (lines +> 1707–1718). This plan supersedes the original Phase 2 outline. + +**Goal:** Add all new schema needed for the Logto integration without +dropping any existing columns, tables, or constraints. Result: the +codebase still builds, the deployed app keeps working unchanged, but +new Logto fields and the multi-sport scaffolding are now available +for Phase 3+ to populate. + +**Branch:** `feature/logto-integration` (continues from Phase 1 tip +`72525e5`). + +**Why additive-only:** Dropping `users.password_hash`, `users.role`, or +`tournament_staff.raw_password` immediately would break 78 + 113 + 1 +references across ~35 Go files (we measured). The original plan's +"shrink users in one migration" approach is incompatible with +additive deployment — we'd have to rewrite every handler that does +`session.Data.Role` *in the same PR* as the schema change. Phase 6 +cutover deletes the old code paths and a follow-up migration drops +the now-orphaned columns. + +**Scope of this phase:** + +- **CREATE** `sports` lookup table + seed Pickleball + Demo Sport +- **CREATE** `player_profiles` 1:1 table (initially empty; populated in + Phase 3 when the frontend starts saving profile data) +- **ADD** `users.logto_user_id` (nullable, UNIQUE when not null) +- **ADD** `sport_id` columns on `tournaments`, `leagues`, + `organizations`, `venues`, `divisions` (nullable, backfilled to + Pickleball, with index — but NOT NOT NULL until Phase 6) +- **ADD** `api_keys.logto_m2m_app_id` (nullable, UNIQUE when not null) +- **NO drops, no renames, no constraint tightenings.** + +**Out of scope (deferred to Phase 6 cutover):** + +- Drop `users.password_hash`, `users.role`, `users.first_name/last_name/date_of_birth`, + and the migrated profile columns (after the data is moved into + `player_profiles` by webhook + on-demand upsert) +- Drop `tournament_staff.raw_password` +- Drop `api_keys.{key_hash, key_prefix, scopes, expires_at}` +- Make `users.logto_user_id` and the various `sport_id` columns NOT NULL +- Remove the now-unused `idx_users_dedup` (which references + `first_name, last_name, date_of_birth`) + +--- + +## Task 2.1 — Write migration `00041_logto_schema_additive.sql` + +**Files:** +- Create: `api/db/migrations/00041_logto_schema_additive.sql` + +**Steps:** + +- [ ] **Step 1.** Create the file with the following contents: + +```sql +-- +goose Up + +-- ============================================================================ +-- Phase 2 of the Logto integration: ADDITIVE schema changes. +-- +-- This migration only ADDS tables, columns, and indexes. It deliberately +-- does not drop any existing schema; that work is deferred to a Phase 6 +-- cutover migration once all Go callers have stopped reading the +-- to-be-removed columns. +-- +-- See docs/DATABASE_OWNERSHIP.md for which fields live where after this +-- migration lands. See docs/superpowers/specs/2026-04-20-logto-integration-design.md +-- for the broader design. +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 1. sports lookup table +-- --------------------------------------------------------------------------- + +CREATE TABLE sports ( + id BIGSERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + logto_org_id TEXT NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_sports_active ON sports(sort_order) WHERE is_active = true; + +-- Seed both sports. logto_org_id values match the Logto orgs created +-- per Step 5 of docs/LOGTO_SETUP.md. If those values change in Logto +-- admin, update them via UPDATE rather than re-running this migration. +INSERT INTO sports (slug, name, logto_org_id, sort_order) VALUES + ('pickleball', 'Pickleball', 'ekup1zyrrxj4', 1), + ('demo_sport', 'Demo Sport', '7866ex96uk6b', 99); + +-- --------------------------------------------------------------------------- +-- 2. users.logto_user_id (nullable mirror FK) +-- --------------------------------------------------------------------------- + +-- Nullable for now: existing rows have no Logto identity, and the +-- Phase 5 webhook + Phase 6 cutover will populate it. NOT NULL is +-- imposed in the cutover migration. +ALTER TABLE users ADD COLUMN logto_user_id TEXT; + +-- Partial UNIQUE index allows multiple NULLs (legacy rows) but enforces +-- one-to-one mapping for any row that does have a Logto user. +CREATE UNIQUE INDEX idx_users_logto_user_id + ON users(logto_user_id) + WHERE logto_user_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 3. player_profiles (initially empty; populated by Phase 3 forms) +-- --------------------------------------------------------------------------- + +-- 1:1 with users by user_id. Lives separately so the users table can +-- shrink to a Logto-mirror shape in Phase 6 without losing profile +-- data. Until Phase 3 starts writing here, every row in users has an +-- implicit empty profile (queries treat absence as defaults). +CREATE TABLE player_profiles ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + phone TEXT, + dupr_id TEXT, + vair_id TEXT, + paddle_brand TEXT, + paddle_model TEXT, + gender TEXT CHECK (gender IN ('male','female','non_binary','prefer_not_to_say')), + handedness TEXT CHECK (handedness IN ('right','left','ambidextrous')), + date_of_birth DATE, + bio TEXT, + address_line_1 TEXT, + address_line_2 TEXT, + city TEXT, + state_province TEXT, + country TEXT, + postal_code TEXT, + formatted_address TEXT, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + emergency_contact_name TEXT, + emergency_contact_phone TEXT, + medical_notes TEXT, + waiver_accepted_at TIMESTAMPTZ, + avatar_url TEXT, + is_profile_hidden BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_player_profiles_dupr_id + ON player_profiles(dupr_id) WHERE dupr_id IS NOT NULL; +CREATE INDEX idx_player_profiles_vair_id + ON player_profiles(vair_id) WHERE vair_id IS NOT NULL; +CREATE INDEX idx_player_profiles_city_state + ON player_profiles(city, state_province); + +-- --------------------------------------------------------------------------- +-- 4. sport_id columns on top-level domain tables +-- --------------------------------------------------------------------------- + +-- All five columns are nullable for now and backfilled to Pickleball. +-- Phase 6 cutover migration imposes NOT NULL after every Go writer has +-- been updated to set sport_id explicitly. + +DO $$ +DECLARE + pickleball_id BIGINT; +BEGIN + SELECT id INTO pickleball_id FROM sports WHERE slug = 'pickleball'; + + -- tournaments + ALTER TABLE tournaments ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE tournaments SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- leagues + ALTER TABLE leagues ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE leagues SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- organizations + ALTER TABLE organizations ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE organizations SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- venues + ALTER TABLE venues ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE venues SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- divisions + ALTER TABLE divisions ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE divisions SET sport_id = pickleball_id WHERE sport_id IS NULL; +END $$; + +CREATE INDEX idx_tournaments_sport + ON tournaments(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_leagues_sport + ON leagues(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_organizations_sport + ON organizations(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_venues_sport + ON venues(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_divisions_sport + ON divisions(sport_id) WHERE deleted_at IS NULL; + +-- --------------------------------------------------------------------------- +-- 5. api_keys.logto_m2m_app_id (nullable mirror FK) +-- --------------------------------------------------------------------------- + +ALTER TABLE api_keys ADD COLUMN logto_m2m_app_id TEXT; + +CREATE UNIQUE INDEX idx_api_keys_logto_m2m_app_id + ON api_keys(logto_m2m_app_id) + WHERE logto_m2m_app_id IS NOT NULL; + + +-- +goose Down + +-- Reverse in opposite order. All operations are conditional so a +-- partial-up migration can be rolled back cleanly. + +DROP INDEX IF EXISTS idx_api_keys_logto_m2m_app_id; +ALTER TABLE api_keys DROP COLUMN IF EXISTS logto_m2m_app_id; + +DROP INDEX IF EXISTS idx_divisions_sport; +DROP INDEX IF EXISTS idx_venues_sport; +DROP INDEX IF EXISTS idx_organizations_sport; +DROP INDEX IF EXISTS idx_leagues_sport; +DROP INDEX IF EXISTS idx_tournaments_sport; + +ALTER TABLE divisions DROP COLUMN IF EXISTS sport_id; +ALTER TABLE venues DROP COLUMN IF EXISTS sport_id; +ALTER TABLE organizations DROP COLUMN IF EXISTS sport_id; +ALTER TABLE leagues DROP COLUMN IF EXISTS sport_id; +ALTER TABLE tournaments DROP COLUMN IF EXISTS sport_id; + +DROP INDEX IF EXISTS idx_player_profiles_city_state; +DROP INDEX IF EXISTS idx_player_profiles_vair_id; +DROP INDEX IF EXISTS idx_player_profiles_dupr_id; +DROP TABLE IF EXISTS player_profiles; + +DROP INDEX IF EXISTS idx_users_logto_user_id; +ALTER TABLE users DROP COLUMN IF EXISTS logto_user_id; + +DROP INDEX IF EXISTS idx_sports_active; +DROP TABLE IF EXISTS sports; +``` + +- [ ] **Step 2.** Verify the migration file syntax with a local sanity + check (no DB needed): + + ```bash + cd api + # Just confirm the file parses; goose will fully validate on apply. + grep -c "^-- +goose " db/migrations/00041_logto_schema_additive.sql + # Expect: 2 (one Up, one Down) + ``` + +**Success criteria for Task 2.1:** the migration file exists, has both +Up and Down sections, and `goose validate` (run later in Task 2.4) +passes. + +--- + +## Task 2.2 — Add new sqlc queries + +**Files:** +- Create: `api/db/queries/sports.sql` +- Create: `api/db/queries/player_profiles.sql` +- Modify: `api/db/queries/users.sql` (add Logto-aware lookups; do NOT + remove existing queries) +- Modify: `api/db/queries/api_keys.sql` (add Logto-aware lookups; do + NOT remove existing queries) + +**Steps:** + +- [ ] **Step 1.** Create `api/db/queries/sports.sql`: + +```sql +-- api/db/queries/sports.sql + +-- name: ListSports :many +SELECT * FROM sports +WHERE is_active = true +ORDER BY sort_order, name; + +-- name: GetSportByID :one +SELECT * FROM sports WHERE id = $1; + +-- name: GetSportBySlug :one +SELECT * FROM sports WHERE slug = $1; + +-- name: GetSportByLogtoOrgID :one +SELECT * FROM sports WHERE logto_org_id = $1; +``` + +- [ ] **Step 2.** Create `api/db/queries/player_profiles.sql`: + +```sql +-- api/db/queries/player_profiles.sql + +-- name: GetPlayerProfile :one +SELECT * FROM player_profiles WHERE user_id = $1; + +-- name: UpsertPlayerProfile :one +-- Insert or update the 1:1 profile row. The full set of columns is +-- accepted on every call; pass NULL for fields the caller does not +-- want to change AFTER reading the existing row first (or use the +-- nullable narg pattern below for partial updates). +INSERT INTO player_profiles ( + user_id, + phone, dupr_id, vair_id, paddle_brand, paddle_model, + gender, handedness, date_of_birth, bio, + address_line_1, address_line_2, city, state_province, country, + postal_code, formatted_address, latitude, longitude, + emergency_contact_name, emergency_contact_phone, medical_notes, + waiver_accepted_at, avatar_url, is_profile_hidden, + updated_at +) VALUES ( + $1, + $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, + $23, $24, $25, + now() +) +ON CONFLICT (user_id) DO UPDATE SET + phone = EXCLUDED.phone, + dupr_id = EXCLUDED.dupr_id, + vair_id = EXCLUDED.vair_id, + paddle_brand = EXCLUDED.paddle_brand, + paddle_model = EXCLUDED.paddle_model, + gender = EXCLUDED.gender, + handedness = EXCLUDED.handedness, + date_of_birth = EXCLUDED.date_of_birth, + bio = EXCLUDED.bio, + address_line_1 = EXCLUDED.address_line_1, + address_line_2 = EXCLUDED.address_line_2, + city = EXCLUDED.city, + state_province = EXCLUDED.state_province, + country = EXCLUDED.country, + postal_code = EXCLUDED.postal_code, + formatted_address = EXCLUDED.formatted_address, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + emergency_contact_name = EXCLUDED.emergency_contact_name, + emergency_contact_phone = EXCLUDED.emergency_contact_phone, + medical_notes = EXCLUDED.medical_notes, + waiver_accepted_at = EXCLUDED.waiver_accepted_at, + avatar_url = EXCLUDED.avatar_url, + is_profile_hidden = EXCLUDED.is_profile_hidden, + updated_at = now() +RETURNING *; + +-- name: DeletePlayerProfile :exec +DELETE FROM player_profiles WHERE user_id = $1; +``` + +- [ ] **Step 3.** Append to `api/db/queries/users.sql`: + +```sql +-- name: GetUserByLogtoUserID :one +SELECT * FROM users +WHERE logto_user_id = $1 AND deleted_at IS NULL; + +-- name: SetUserLogtoUserID :one +-- Phase 5 webhook + on-demand upsert path: bind a Logto user ID to an +-- existing local mirror row. Idempotent: setting the same value twice +-- is fine; setting a different value when one is already set fails on +-- the UNIQUE index. +UPDATE users SET + logto_user_id = $2, + updated_at = now() +WHERE id = $1 AND deleted_at IS NULL +RETURNING *; +``` + +- [ ] **Step 4.** Append to `api/db/queries/api_keys.sql`: + +```sql +-- name: GetAPIKeyByLogtoM2MAppID :one +SELECT * FROM api_keys +WHERE logto_m2m_app_id = $1; + +-- name: SetAPIKeyLogtoM2MAppID :one +UPDATE api_keys SET + logto_m2m_app_id = $2, + updated_at = now() +WHERE id = $1 +RETURNING *; +``` + +**Success criteria for Task 2.2:** all four query files have the new +queries; sqlc can parse them (Task 2.3 verifies). + +--- + +## Task 2.3 — Regenerate sqlc + verify build clean + +**Files:** +- Generated (auto): `api/db/generated/*.sql.go` and + `api/db/generated/models.go` + +**Steps:** + +- [ ] **Step 1.** Confirm sqlc is available locally: + + ```bash + which sqlc || go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest + sqlc version + ``` + +- [ ] **Step 2.** Regenerate from the api directory: + + ```bash + cd api + sqlc generate + ``` + + Expected: no errors. New generated functions: + - `db/generated/sports.sql.go` with `ListSports`, `GetSportByID`, + `GetSportBySlug`, `GetSportByLogtoOrgID` + - `db/generated/player_profiles.sql.go` with `GetPlayerProfile`, + `UpsertPlayerProfile`, `DeletePlayerProfile` + - Updated `db/generated/users.sql.go` with `GetUserByLogtoUserID`, + `SetUserLogtoUserID` + - Updated `db/generated/api_keys.sql.go` with + `GetAPIKeyByLogtoM2MAppID`, `SetAPIKeyLogtoM2MAppID` + - `db/generated/models.go` gets new `Sport`, `PlayerProfile` structs; + existing `User` and `ApiKey` structs gain `LogtoUserID` / + `LogtoM2mAppID` fields. + +- [ ] **Step 3.** Verify build is clean: + + ```bash + cd api + go build ./... + go vet ./... + ``` + + Expected: both exit 0. Any failure here means the migration's + schema doesn't match what sqlc inferred — re-read the migration + carefully. + +- [ ] **Step 4.** Verify existing tests still pass for the packages + that don't touch the DB: + + ```bash + cd api + go test ./auth/... ./logto/... ./middleware/... -race + ``` + + Expected: all PASS. + +**Success criteria for Task 2.3:** `go build ./...` clean, +`go vet ./...` clean, generated code committed. + +--- + +## Task 2.4 — Smoke test the migration + +The codebase runs migrations automatically on startup +(`db.RunMigrations(ctx, cfg.DatabaseURL)` in `api/main.go:42`). So the +production smoke test is implicit: when Coolify deploys the next +build that includes 00041, the migration runs. + +But we want to catch errors BEFORE that. Local Postgres is the +reliable path; if Postgres is unavailable, fall back to Coolify +deploy verification. + +**Files:** none (verification step). + +**Steps:** + +- [ ] **Step 1 (preferred).** Run the migration locally against a + fresh ephemeral Postgres. + + Install Postgres if missing: + ```bash + sudo pacman -S --noconfirm postgresql + sudo -u postgres initdb -D /var/lib/postgres/data 2>/dev/null || true + sudo systemctl enable --now postgresql + ``` + + Create a throwaway DB: + ```bash + sudo -u postgres createuser -s phoenix 2>/dev/null || true + createdb cc_logto_smoke + ``` + + Run the full migration set: + ```bash + cd api + DATABASE_URL=postgres://phoenix@localhost:5432/cc_logto_smoke?sslmode=disable \ + go run ./cmd/migrate up || \ + DATABASE_URL=postgres://phoenix@localhost:5432/cc_logto_smoke?sslmode=disable \ + go test -run TestMigrationsUp ./db -v + ``` + + (If neither cmd nor TestMigrationsUp exists, run via the running + binary's startup migration: `DATABASE_URL=... go run main.go &` and + watch the logs.) + + Verify the new tables and columns: + ```bash + psql cc_logto_smoke -c "\d sports" + psql cc_logto_smoke -c "\d player_profiles" + psql cc_logto_smoke -c "SELECT slug, name, logto_org_id FROM sports;" + psql cc_logto_smoke -c "\d users" # confirm logto_user_id column + psql cc_logto_smoke -c "\d api_keys" # confirm logto_m2m_app_id column + psql cc_logto_smoke -c "\d tournaments" # confirm sport_id column + ``` + + Expected: 2 sports rows (pickleball, demo_sport), all columns and + indexes present. + + Test the rollback: + ```bash + goose -dir api/db/migrations \ + postgres "postgres://phoenix@localhost:5432/cc_logto_smoke?sslmode=disable" \ + down + ``` + + Verify the rollback succeeded: + ```bash + psql cc_logto_smoke -c "\d sports" # should not exist + psql cc_logto_smoke -c "\d player_profiles" # should not exist + psql cc_logto_smoke -c "\d users" # logto_user_id column gone + ``` + + Re-run up to confirm it can be re-applied: + ```bash + goose -dir api/db/migrations postgres "..." up + ``` + +- [ ] **Step 2 (fallback).** If local Postgres is not available, push + the migration to `feature/logto-integration` and let Coolify run it. + Watch the deploy log for migration output. The api startup logs + will show `running database migrations` followed by goose's per- + migration log lines. After deploy, verify by adding a brief logging + trace in the app or by querying via a one-shot psql container in + Coolify. + + This is less safe (can't easily roll back without code changes) but + acceptable given the additive-only design — even a botched 00041 is + a non-event because the migration only adds; nothing breaks if the + Down half is buggy because we don't intend to run it. + +**Success criteria for Task 2.4:** migration applies cleanly (no error +output from goose), all expected schema elements exist, rollback also +clean. + +--- + +## Verification and exit criteria for Phase 2 + +- [ ] `api/db/migrations/00041_logto_schema_additive.sql` committed +- [ ] `api/db/queries/sports.sql` and `player_profiles.sql` committed; + `users.sql` and `api_keys.sql` augmented (no removals) +- [ ] `api/db/generated/` contains the regenerated bindings +- [ ] `go build ./...` clean +- [ ] `go vet ./...` clean +- [ ] `go test ./auth/... ./logto/... ./middleware/... -race` clean +- [ ] Migration up/down validated on a real Postgres (or, fallback, + cleanly applied via Coolify deploy) +- [ ] Live smoke verifies `/api/v1/health` still 200 OK (i.e. the + migration didn't break the app's startup path) +- [ ] PR description / commit messages clearly note the additive-only + scope and what's deferred to Phase 6 + +--- + +## Risk register + +| Risk | Mitigation | +|---|---| +| The hardcoded `logto_org_id` values for Pickleball/Demo Sport drift if the operator regenerates Logto orgs | Documented in `docs/LOGTO_SETUP.md` Step 5; the seed is data, not code, and can be UPDATEd post-deploy. | +| sqlc generation surprises us with a name collision or shape mismatch | Pre-flight Step 3 of Task 2.3 will fail fast; we adjust the queries. | +| Migration takes longer than Coolify's deploy timeout on a large prod table | All ALTER TABLEs are metadata-only operations on Postgres 17 (no rewrites for nullable adds); UPDATEs only set sport_id on rows that have NULL, which is fast on tables with <100k rows. Production has zero users so this is trivial. | +| `idx_users_dedup` references soon-to-be-removed columns | Out of scope for Phase 2; leave it alone. Phase 6 cutover handles the drop. | +| Phase 3 expects player_profiles to be populated already | Phase 3 will write profile data on form submit (UpsertPlayerProfile). Existing user rows have no profile until the user edits their profile in the new UI. Acceptable for a zero-user environment. | diff --git a/docs/superpowers/plans/2026-05-01-logto-phase-3-frontend.md b/docs/superpowers/plans/2026-05-01-logto-phase-3-frontend.md new file mode 100644 index 0000000..f041509 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-logto-phase-3-frontend.md @@ -0,0 +1,3355 @@ +# Logto Integration Phase 3 — Frontend Auth Swap + Multi-Sport Routing + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the cookie-session SPA auth with Logto OIDC; restructure routes under a `/$sport/*` segment; build a sport-picker landing page; rewrite the profile-edit form against the new `player_profiles` table; add a webhook + on-demand mirror for Logto users; verify end-to-end with a Playwright smoke test. + +**Architecture:** +- Frontend: `@logto/react` SDK wraps `App` in a `LogtoProvider`. `useAuth` becomes a thin shim over `useLogto()`. `apiFetch` attaches `Bearer ` (organization-scoped, with `read_all manage_*` scopes) instead of relying on cookies. Routes restructure: `/` is the sport picker, `/$sport/*` is everything sport-scoped, `/public/*` and `/overlay/*` stay at root, `/auth/callback` handles the OIDC redirect. +- Backend: Add `/api/v1/webhooks/logto` (HMAC-verified) to handle `User.Created`/`User.Data.Updated`/`User.Deleted`; add a "mirror-on-demand" branch in `RequireJWT` that fetches from Logto Mgmt API and upserts the local `users` row when the JWT subject isn't found. Wire `/api/v1/auth/me` to read claims from context. +- Sport routing: `useParams({from: '/$sport'})` gives the sport slug; a `SportContext` provider derives the Logto org ID via `/api/v1/sports` lookup and exposes both. The X-Sport request header is added by `apiFetch` from the active sport. + +**Tech Stack:** React 19, TanStack Router (file-based), TanStack Query, `@logto/react` 4.x, Vite, Playwright (new). + +--- + +## Pre-flight + +Phase 3 is large (10 tasks, ~3-4 days of focused work). It depends on Phase 1 (JWT validator + middleware) and Phase 2 (`sports` table, `player_profiles`, `users.logto_user_id`). Both are merged on `feature/logto-integration` already. + +**Before starting:** confirm the local dev stack is up: +```bash +cd ~/code/court-command-v2/court-command +docker compose -f docker-compose.dev.yml ps +# All three containers should be healthy +curl -s http://localhost:8080/api/v1/health | jq '.status' +# Should print "ok" +``` + +If anything is red, fix first. Phase 3 cannot be developed against a broken local stack. + +--- + +## File Structure + +### New files + +| Path | Purpose | +|------|---------| +| `web/src/auth/LogtoConfig.ts` | Logto SDK configuration (endpoint, app ID, resources, scopes) | +| `web/src/auth/AuthProvider.tsx` | Wraps `LogtoProvider`; reads env vars; signs in to currently selected sport's org | +| `web/src/auth/useAuth.ts` | New hook: thin shim over `useLogto()` exposing `isAuthenticated`, `user`, `signIn`, `signOut`, `getAccessToken` | +| `web/src/auth/SportContext.tsx` | React context: current sport slug + Logto org ID, derived from URL `$sport` param | +| `web/src/auth/useSport.ts` | Hook: `useSport()` returns `{slug, orgID, name}` for the active sport | +| `web/src/lib/sports.ts` | API: `getSports()` fetches `/api/v1/sports` (slug → orgID lookup table) | +| `web/src/routes/index.tsx` | Sport picker landing page (replaces existing dashboard at `/`) | +| `web/src/routes/auth.callback.tsx` | OIDC callback handler — calls `handleSignInCallback`, redirects to last sport | +| `web/src/routes/$sport.tsx` | Layout route for the `$sport` segment; reads slug, sets `SportContext`, redirects to `/` if unknown | +| `web/src/routes/$sport/dashboard.tsx` | Replaces `routes/dashboard.tsx` | +| `web/src/routes/$sport/profile.tsx` | Profile edit form against `player_profiles` (sectioned, all 25 fields) | +| `web/src/routes/$sport/[admin/leagues/manage/tournaments/...]` | All ~48 sport-scoped routes moved here from root | +| `web/tests/e2e/auth-flow.spec.ts` | Playwright E2E smoke test | +| `web/playwright.config.ts` | Playwright config (chromium-only, headless, against `localhost:5173`) | +| `api/handler/webhooks_logto.go` | Webhook handler (HMAC-verified, dispatches on `event` field) | +| `api/handler/webhooks_logto_test.go` | Unit tests for handler (signature verify + each event branch) | +| `api/handler/sports.go` | New handler: `GET /api/v1/sports` returns active sports for the picker | +| `api/handler/profile.go` | New handler: `GET /api/v1/me/profile`, `PATCH /api/v1/me/profile` reads/writes `player_profiles` | +| `api/middleware/mirror_user.go` | After RequireJWT: if no local `users` row for `claims.Subject`, fetch from Logto + upsert | + +### Modified files + +| Path | Change | +|------|--------| +| `web/src/main.tsx` | Wrap `` in `` | +| `web/src/App.tsx` | Add `` (set by `$sport.tsx` route, but provider lives high) | +| `web/src/lib/api.ts` | Replace `credentials: 'include'` with `Authorization: Bearer ` + `X-Sport: `; pull token + slug from context | +| `web/src/features/auth/hooks.ts` | DELETE the file. Functionality moves to `web/src/auth/useAuth.ts` | +| `web/src/features/auth/AuthGuard.tsx` | Rewrite: redirect to `/login-redirect` (which calls `signIn`) instead of `/login` page | +| `web/src/routes/__root.tsx` | Update `NO_SHELL_ROUTES` and `PUBLIC_ROUTE_PATTERNS`; remove `/login` and `/register` patterns | +| `web/src/routes/login.tsx` | DELETE — Logto handles login UI | +| `web/src/routes/register.tsx` | DELETE — Logto handles signup UI | +| `web/src/routes/profile.tsx` | DELETE — moved to `$sport/profile.tsx` and rewritten | +| `web/src/components/Sidebar.tsx`, etc. | Update internal `` and `navigate({to:})` to include `/$sport/` prefix where appropriate | +| `web/package.json` | Add `@logto/react`, `@playwright/test` | +| `api/router/router.go` | Mount `/api/v1/webhooks/logto`, `/api/v1/sports`, `/api/v1/me/profile`; chain `MirrorUser` middleware after `RequireJWT` for `/me/*` routes | +| `api/handler/auth.go` | Rewrite `/api/v1/auth/me`: read claims from context, fetch local user mirror, return same shape as before | +| `api/handler/auth.go` (cookie endpoints) | Keep `/api/v1/auth/login`, `/register`, `/logout` operational for now — Phase 6 deletes them | + +### Deleted files (end-of-Phase-3) + +- `web/src/routes/login.tsx` +- `web/src/routes/register.tsx` +- `web/src/routes/profile.tsx` (moved + rewritten) +- `web/src/routes/dashboard.tsx` (moved) +- `web/src/features/auth/hooks.ts` (replaced) +- ~46 other route files moved (not deleted; relocated) + +--- + +## Testing strategy + +- **Unit tests** for new backend handlers (webhook, profile, sports, mirror middleware). Each follows the existing test pattern (httptest server, mock pool via pgxmock). +- **No frontend unit tests** in this phase — the components are thin wrappers over Logto SDK + TanStack Router; integration via Playwright is more valuable. +- **One Playwright E2E test** covering the full happy path: sport picker → Logto signin → callback → dashboard → profile edit → save. Run as part of Task 10 verification. +- **Manual smoke checklist** at end of plan for things Playwright can't easily cover (multiple browsers, real-device responsive, OBS overlay still works post-restructure). + +--- + +## Task 1 — Install Logto SDK and configure + +**Files:** +- Create: `web/src/auth/LogtoConfig.ts` +- Create: `web/src/auth/AuthProvider.tsx` +- Modify: `web/src/main.tsx` +- Modify: `web/package.json` (via pnpm add) + +- [ ] **Step 1.1: Install dependencies** + +```bash +cd web && pnpm add @logto/react@^4 +``` + +Verify in `package.json`: +```json +"@logto/react": "^4.x.x" +``` + +- [ ] **Step 1.2: Create `LogtoConfig.ts`** + +```ts +// web/src/auth/LogtoConfig.ts +// +// Logto SDK configuration. The SDK uses these to drive the OIDC flow: +// authorization endpoint, token endpoint, JWKS, etc. — discovered via +// the .well-known endpoint of `endpoint`. + +import type { LogtoConfig } from '@logto/react' +import { UserScope } from '@logto/react' + +const endpoint = import.meta.env.VITE_LOGTO_ENDPOINT +const appId = import.meta.env.VITE_LOGTO_APP_ID +const apiResource = import.meta.env.VITE_LOGTO_API_RESOURCE + +if (!endpoint || !appId || !apiResource) { + throw new Error( + 'Missing Logto config. Set VITE_LOGTO_ENDPOINT, VITE_LOGTO_APP_ID, and VITE_LOGTO_API_RESOURCE in .env', + ) +} + +// Scopes requested at sign-in. The 12 API scopes correspond to the +// resource scopes provisioned by api/cmd/logto-seed. Org scopes +// (manage_tournaments, etc.) are requested separately when getting an +// org-scoped token. +export const API_SCOPES = [ + 'read:profile', + 'write:profile', + 'read:tournaments', + 'write:tournaments', + 'read:matches', + 'write:matches', + 'read:registrations', + 'write:registrations', + 'read:overlay', + 'write:overlay', + 'read:admin', + 'write:admin', +] as const + +export const ORG_SCOPES = [ + 'manage_tournaments', + 'manage_matches', + 'manage_registrations', + 'manage_users', + 'read_all', +] as const + +export const logtoConfig: LogtoConfig = { + endpoint, + appId, + resources: [apiResource], + scopes: [...API_SCOPES, UserScope.Email, UserScope.Profile, UserScope.Identities], + // Organization tokens are requested per-sport; we don't list them here. +} + +// Public for tests + the SportContext to use when calling getOrganizationToken. +export const API_RESOURCE = apiResource +``` + +- [ ] **Step 1.3: Create `AuthProvider.tsx`** + +```tsx +// web/src/auth/AuthProvider.tsx +// +// Top-level auth provider. Wraps the entire app in . +// Doesn't contain its own state; the actual auth-related hooks live in +// useAuth.ts. + +import { LogtoProvider } from '@logto/react' +import type { ReactNode } from 'react' +import { logtoConfig } from './LogtoConfig' + +export function AuthProvider({ children }: { children: ReactNode }) { + return {children} +} +``` + +- [ ] **Step 1.4: Wrap App in main.tsx** + +```tsx +// web/src/main.tsx +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import { AuthProvider } from './auth/AuthProvider' +import './styles.css' + +const root = document.getElementById('root')! +createRoot(root).render( + + + + + , +) +``` + +- [ ] **Step 1.5: Verify it builds** + +```bash +cd web && pnpm build +``` +Expected: clean build, no TypeScript errors. The app won't be runnable yet (no callback route, no useAuth) but it should compile. + +- [ ] **Step 1.6: Commit** + +```bash +git add web/package.json web/pnpm-lock.yaml web/src/auth/ web/src/main.tsx +git commit -m "feat(auth): scaffold Logto SDK config and AuthProvider + +Adds @logto/react and the LogtoConfig + AuthProvider scaffolding. +Reads VITE_LOGTO_ENDPOINT, VITE_LOGTO_APP_ID, VITE_LOGTO_API_RESOURCE +from .env (set by the local seeder). No behavior change yet — the +provider wraps the app but no component consumes useLogto." +``` + +--- + +## Task 2 — Build the new useAuth hook + delete the old one + +**Files:** +- Create: `web/src/auth/useAuth.ts` +- Delete: `web/src/features/auth/hooks.ts` +- Modify: `web/src/features/auth/AuthGuard.tsx` (to use new hook) + +- [ ] **Step 2.1: Create `useAuth.ts`** + +```ts +// web/src/auth/useAuth.ts +// +// Replacement for features/auth/hooks.ts. +// Thin shim over @logto/react's useLogto(): exposes the same shape the +// existing app expects (user object, isAuthenticated, isLoading) plus +// a getAccessToken function used by api.ts. + +import { useLogto } from '@logto/react' +import { useQuery } from '@tanstack/react-query' +import { API_RESOURCE } from './LogtoConfig' + +export interface User { + public_id: string + email: string + first_name: string + last_name: string + display_name: string | null + date_of_birth: string | null + role: string + status: string + created_at: string + impersonation?: { active: boolean; impersonator_id: string } | null +} + +export function useAuth() { + const { isAuthenticated, isLoading: logtoLoading, signIn, signOut, getIdTokenClaims } = useLogto() + + // Local mirror — fetched from /api/v1/auth/me once the JWT is issued. + // Backend reads claims from context (set by RequireJWT) and returns + // the local users row. + const me = useQuery({ + queryKey: ['auth', 'me'], + queryFn: async () => { + // apiGet defined later (Task 3) handles 401 -> null. For now during + // the migration, fall back to id_token claims if /me 404s (mirror + // not yet provisioned). + const { apiGet } = await import('../lib/api') + try { + return await apiGet('/api/v1/auth/me') + } catch (err: any) { + if (err.status === 401 || err.status === 404) return null + throw err + } + }, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + retry: false, + }) + + return { + user: me.data ?? null, + isLoading: logtoLoading || me.isLoading, + isAuthenticated: !!isAuthenticated && !!me.data, + isImpersonating: !!me.data?.impersonation?.active, + error: me.error, + signIn: (returnTo: string) => + signIn({ redirectUri: `${window.location.origin}/auth/callback`, postRedirectUri: returnTo }), + signOut: (returnTo: string = '/') => signOut(`${window.location.origin}${returnTo}`), + getIdTokenClaims, + } +} + +// Keep the named exports the legacy callers use, so route migration +// doesn't have to touch every file. +export { useAuth as useAuthQuery } // alias for any old call sites +export function useLogout() { + const { signOut } = useAuth() + return { + mutate: () => signOut('/'), + mutateAsync: async () => signOut('/'), + isPending: false, + } +} +``` + +- [ ] **Step 2.2: Update `AuthGuard.tsx`** + +```tsx +// web/src/features/auth/AuthGuard.tsx +import { useEffect } from 'react' +import { useLocation, useNavigate } from '@tanstack/react-router' +import { useAuth } from '../../auth/useAuth' + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading, signIn } = useAuth() + const location = useLocation() + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + // Trigger the OIDC sign-in flow. After successful sign-in, + // the callback route will redirect back to where they were. + void signIn(location.href) + } + }, [isLoading, isAuthenticated, location.href, signIn]) + + if (isLoading) return
Loading…
+ if (!isAuthenticated) return
Redirecting to sign in…
+ return <>{children} +} +``` + +- [ ] **Step 2.3: Delete the old hooks file** + +```bash +git rm web/src/features/auth/hooks.ts +``` + +- [ ] **Step 2.4: Update imports across the codebase** + +Search and replace every import: +```bash +cd web/src +grep -rl "from '.*features/auth/hooks'" --include="*.tsx" --include="*.ts" +# For each file, change the import path from +# '../features/auth/hooks' (or however many ../) to +# '../auth/useAuth' (or however many ../) +``` + +Do NOT bulk-sed this; the relative paths differ per file. Use your editor's "find usages" or do it file-by-file. + +- [ ] **Step 2.5: Verify it builds** + +```bash +cd web && pnpm build +``` + +Expected: clean. If anything fails to compile because `useLogin`/`useRegister` are no longer exported (we deleted them), comment those imports out in the corresponding route files (`login.tsx`, `register.tsx`) — those files are deleted in Task 6. + +- [ ] **Step 2.6: Commit** + +```bash +git add web/src/auth/useAuth.ts web/src/features/auth/AuthGuard.tsx +git rm web/src/features/auth/hooks.ts +git commit -m "feat(auth): replace cookie-based useAuth with Logto-backed hook + +useAuth now wraps useLogto() from @logto/react. AuthGuard triggers +signIn() via OIDC redirect instead of pushing to /login. The local +'users' mirror is still loaded from /api/v1/auth/me and exposed under +the same User shape, so existing callers don't change." +``` + +--- + +## Task 3 — Rewrite apiFetch to attach Bearer token + X-Sport header + +**Files:** +- Modify: `web/src/lib/api.ts` + +- [ ] **Step 3.1: Lift token + sport into module state** + +The current `api.ts` has zero context (no React). Two ways to fix: + +**Chosen approach:** Module-level setter functions called by `AuthProvider` and `SportContext`. Avoids prop-drilling; preserves the function-call API everywhere else. + +Replace `web/src/lib/api.ts` entirely: + +```ts +// web/src/lib/api.ts +// +// Phase 3 swap: cookie-based fetch -> Bearer-token fetch. +// Token + sport slug are pushed into module state by AuthProvider / +// SportContext rather than read from React context inside each call, +// so call sites (apiGet, apiPost) keep their plain-function shape. + +const API_BASE = import.meta.env.VITE_API_URL || '' + +// Set by SportContext when the active $sport changes. Empty string = +// not on a sport-scoped route (sport picker, public routes). +let currentSportSlug = '' +export function setCurrentSport(slug: string) { + currentSportSlug = slug +} + +// Set by AuthProvider after sign-in. A function so we always pull the +// freshest token (Logto SDK handles refresh transparently). +let getAccessTokenFn: ((resource: string) => Promise) | null = null +export function setGetAccessTokenFn(fn: typeof getAccessTokenFn) { + getAccessTokenFn = fn +} + +const API_RESOURCE = import.meta.env.VITE_LOGTO_API_RESOURCE + +export interface ApiError { + code: string + message: string +} + +export class ApiRequestError extends Error { + code: string + status: number + constructor(status: number, code: string, message: string) { + super(message) + this.name = 'ApiRequestError' + this.code = code + this.status = status + } +} + +async function buildHeaders(extra?: HeadersInit): Promise { + const h = new Headers(extra) + if (getAccessTokenFn) { + const token = await getAccessTokenFn(API_RESOURCE) + if (token) h.set('Authorization', `Bearer ${token}`) + } + if (currentSportSlug) h.set('X-Sport', currentSportSlug) + return h +} + +async function handleResponse(response: Response): Promise { + if (!response.ok) await throwApiError(response) + if (response.status === 204) return undefined as unknown as T + const body = await response.json() + return body.data !== undefined ? body.data : body +} + +export async function apiGet(path: string): Promise { + const headers = await buildHeaders() + const response = await fetch(`${API_BASE}${path}`, { headers }) + return handleResponse(response) +} + +export async function apiPost(path: string, body?: unknown): Promise { + const headers = await buildHeaders(body ? { 'Content-Type': 'application/json' } : undefined) + const response = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body: body ? JSON.stringify(body) : undefined, + }) + return handleResponse(response) +} + +export async function apiPatch(path: string, body: unknown): Promise { + const headers = await buildHeaders({ 'Content-Type': 'application/json' }) + const response = await fetch(`${API_BASE}${path}`, { + method: 'PATCH', + headers, + body: JSON.stringify(body), + }) + return handleResponse(response) +} + +export async function apiPut(path: string, body: unknown): Promise { + const headers = await buildHeaders({ 'Content-Type': 'application/json' }) + const response = await fetch(`${API_BASE}${path}`, { + method: 'PUT', + headers, + body: JSON.stringify(body), + }) + return handleResponse(response) +} + +export async function apiDelete(path: string): Promise { + const headers = await buildHeaders() + const response = await fetch(`${API_BASE}${path}`, { method: 'DELETE', headers }) + return handleResponse(response) +} + +export interface PaginatedData { + items: T[]; total: number; limit: number; offset: number +} + +export async function apiGetPaginated(path: string): Promise> { + const headers = await buildHeaders() + const response = await fetch(`${API_BASE}${path}`, { headers }) + if (!response.ok) await throwApiError(response) + const body = await response.json() + return { + items: body.data || [], + total: body.pagination?.total || 0, + limit: body.pagination?.limit || 20, + offset: body.pagination?.offset || 0, + } +} + +async function throwApiError(response: Response): Promise { + let code = 'unknown_error' + let message = `Request failed with status ${response.status}` + try { + const body = await response.json() + if (body.error) { + code = body.error.code || code + message = body.error.message || message + } + } catch { + // not JSON + } + throw new ApiRequestError(response.status, code, message) +} +``` + +- [ ] **Step 3.2: Wire `setGetAccessTokenFn` into AuthProvider** + +```tsx +// web/src/auth/AuthProvider.tsx +import { LogtoProvider, useLogto } from '@logto/react' +import { useEffect, type ReactNode } from 'react' +import { logtoConfig } from './LogtoConfig' +import { setGetAccessTokenFn } from '../lib/api' + +function TokenWiring({ children }: { children: ReactNode }) { + const { getAccessToken } = useLogto() + useEffect(() => { + setGetAccessTokenFn(async (resource) => { + try { + return (await getAccessToken(resource)) ?? null + } catch { + return null + } + }) + return () => setGetAccessTokenFn(null) + }, [getAccessToken]) + return <>{children} +} + +export function AuthProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} +``` + +- [ ] **Step 3.3: Verify it builds** + +```bash +cd web && pnpm build +``` + +- [ ] **Step 3.4: Commit** + +```bash +git add web/src/lib/api.ts web/src/auth/AuthProvider.tsx +git commit -m "feat(auth): apiFetch attaches Bearer token + X-Sport header + +Replaces credentials: 'include' with Authorization: Bearer +and X-Sport: . Token comes from useLogto().getAccessToken() (freshly +minted per request, refreshed transparently by the SDK). Sport slug is +set by SportContext (Task 5)." +``` + +--- + +## Task 4 — Build SportContext + sports API + +**Files:** +- Create: `api/handler/sports.go` +- Create: `api/handler/sports_test.go` +- Modify: `api/router/router.go` (mount `/api/v1/sports`) +- Create: `web/src/lib/sports.ts` +- Create: `web/src/auth/SportContext.tsx` +- Create: `web/src/auth/useSport.ts` + +- [ ] **Step 4.1: Backend — `GET /api/v1/sports` handler** + +```go +// api/handler/sports.go +package handler + +import ( + "net/http" + + "github.com/court-command/court-command/db/generated" +) + +type SportDTO struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + LogtoOrgID string `json:"logto_org_id"` +} + +// ListSports returns active sports ordered by sort_order. +// Public (no auth required) — the sport picker lives at the unauthenticated +// landing page. +func (h *Handler) ListSports(w http.ResponseWriter, r *http.Request) { + sports, err := h.queries.ListSports(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "list sports") + return + } + out := make([]SportDTO, len(sports)) + for i, s := range sports { + out[i] = SportDTO{ + ID: s.ID, + Slug: s.Slug, + Name: s.Name, + LogtoOrgID: s.LogtoOrgID, + } + } + writeJSON(w, http.StatusOK, map[string]any{"data": out}) +} + +// guard against unused import if generated isn't referenced directly elsewhere +var _ = generated.Sport{} +``` + +- [ ] **Step 4.2: Test the handler** + +```go +// api/handler/sports_test.go +package handler_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/court-command/court-command/db/generated" + "github.com/court-command/court-command/handler" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +type fakeQueries struct { + sports []generated.Sport + err error +} + +func (f *fakeQueries) ListSports(ctx context.Context) ([]generated.Sport, error) { + return f.sports, f.err +} + +// Plus stubs for any other queries the Handler depends on; copy from +// existing handler tests' fake queries fixture. + +func TestListSports_ReturnsActiveSports(t *testing.T) { + q := &fakeQueries{sports: []generated.Sport{ + {ID: 1, Slug: "pickleball", Name: "Pickleball", LogtoOrgID: "ekup1zyrrxj4"}, + {ID: 2, Slug: "demo_sport", Name: "Demo Sport", LogtoOrgID: "7866ex96uk6b"}, + }} + h := handler.NewWithQueries(q) // assumes a test constructor; if not present, add one + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sports", nil) + rr := httptest.NewRecorder() + h.ListSports(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + var resp map[string][]map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + require.Len(t, resp["data"], 2) + require.Equal(t, "pickleball", resp["data"][0]["slug"]) +} + +// silence unused +var _ = pgconn.PgError{} +``` + +If the existing handler package doesn't have a test-friendly constructor, add one: +```go +// api/handler/handler.go (add at bottom) +// +//nolint:revive // exported for test +func NewWithQueries(q QueriesIface) *Handler { + return &Handler{queries: q} +} +``` + +- [ ] **Step 4.3: Run the test** + +```bash +cd api && go test ./handler/... -run TestListSports -v +``` +Expected: PASS. + +- [ ] **Step 4.4: Mount the route** + +In `api/router/router.go`, find the public-route block and add: +```go +r.Get("/api/v1/sports", h.ListSports) +``` + +Verify route placement: this is **public** (no `RequireJWT`). + +- [ ] **Step 4.5: Frontend — `lib/sports.ts`** + +```ts +// web/src/lib/sports.ts +import { apiGet } from './api' + +export interface Sport { + id: number + slug: string + name: string + logto_org_id: string +} + +export async function listSports(): Promise { + return apiGet('/api/v1/sports') +} +``` + +- [ ] **Step 4.6: Frontend — `SportContext.tsx`** + +```tsx +// web/src/auth/SportContext.tsx +import { createContext, useContext, useEffect, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { listSports, type Sport } from '../lib/sports' +import { setCurrentSport } from '../lib/api' + +interface SportCtx { + sport: Sport | null + sports: Sport[] + isLoading: boolean +} + +const Ctx = createContext({ sport: null, sports: [], isLoading: false }) + +export function SportProvider({ slug, children }: { slug: string | null; children: ReactNode }) { + const sportsQuery = useQuery({ + queryKey: ['sports'], + queryFn: listSports, + staleTime: 60 * 60 * 1000, // 1 hour — sports rarely change + }) + + const sport = slug ? sportsQuery.data?.find((s) => s.slug === slug) ?? null : null + + // Push slug into the module-level api state so apiFetch attaches X-Sport. + useEffect(() => { + setCurrentSport(sport?.slug ?? '') + }, [sport?.slug]) + + return ( + + {children} + + ) +} + +export function useSport() { + return useContext(Ctx) +} +``` + +- [ ] **Step 4.7: Verify build + test pass** + +```bash +cd api && go test ./handler/... -run TestListSports -v +cd ../web && pnpm build +``` + +- [ ] **Step 4.8: Commit** + +```bash +git add api/handler/sports.go api/handler/sports_test.go api/handler/handler.go api/router/router.go web/src/lib/sports.ts web/src/auth/SportContext.tsx web/src/auth/useSport.ts +git commit -m "feat(sport): GET /api/v1/sports + SportContext provider + +Backend: public ListSports handler returns active sports ordered by +sort_order. Frontend: SportContext reads the URL \$sport slug, looks +up the matching sport row from the API, pushes the slug into apiFetch +module state so X-Sport header is attached on every request." +``` + +--- + +## Task 5 — Restructure routes under `/$sport/*` + +This is the largest mechanical change in Phase 3. Move ~48 routes under a new `$sport` segment, add a `$sport.tsx` layout, and update every internal link. + +**Files:** ~48 route files moved, 1 layout file created, ~20 link callers updated. + +- [ ] **Step 5.1: Create the `$sport` layout route** + +```tsx +// web/src/routes/$sport.tsx +import { createFileRoute, Outlet, useParams, useNavigate } from '@tanstack/react-router' +import { useEffect } from 'react' +import { SportProvider, useSport } from '../auth/SportContext' + +export const Route = createFileRoute('/$sport')({ + component: SportLayout, +}) + +function SportLayout() { + const { sport: slug } = useParams({ from: '/$sport' }) + return ( + + + + ) +} + +function SportGuard() { + const { sport, sports, isLoading } = useSport() + const navigate = useNavigate() + useEffect(() => { + // Once sports list loads, if the slug isn't a known sport, bounce home. + if (!isLoading && sports.length > 0 && !sport) { + void navigate({ to: '/' }) + } + }, [isLoading, sports, sport, navigate]) + + if (isLoading) return
Loading sport…
+ if (!sport) return null + return +} +``` + +- [ ] **Step 5.2: Decide which routes are sport-scoped** + +| Path family | Goes under `$sport`? | +|---|---| +| `dashboard.tsx` | YES | +| `profile.tsx` | YES | +| `admin/*` | YES | +| `courts/*` | YES | +| `leagues/*` | YES | +| `manage/*` | YES | +| `match-series/*` | YES | +| `matches/*` | YES | +| `organizations/*` | YES | +| `players/*` | YES | +| `quick-match/*` | YES | +| `ref/*` | YES | +| `scorekeeper/*` | YES | +| `teams/*` | YES | +| `tournaments/*` | YES | +| `index.tsx` (currently dashboard redirect) | DELETE — replaced with sport picker | +| `login.tsx`, `register.tsx` | DELETE — Logto handles | +| `auth.callback.tsx` | NEW — root level, not sport-scoped | +| `public/*` | NO — stays at `/public/*` | +| `overlay/*` | NO — stays at `/overlay/*` (renders inside OBS, no sport context) | +| `tv/*` (if exists) | NO | +| `__root.tsx` | NO — root layout | + +Search for any not in this list and decide: +```bash +cd web/src/routes && ls *.tsx +``` + +- [ ] **Step 5.3: Move the routes (mechanical)** + +```bash +cd web/src/routes +mkdir -p '$sport' + +# Move directories +for d in admin courts leagues manage match-series matches organizations players quick-match ref scorekeeper teams tournaments; do + if [ -d "$d" ]; then + git mv "$d" '$sport/'"$d" + fi +done + +# Move standalone files (rename to fit new path) +for f in dashboard.tsx profile.tsx; do + if [ -f "$f" ]; then + git mv "$f" '$sport/'"$f" + fi +done +``` + +TanStack Router uses **file path** as route — so `routes/$sport/dashboard.tsx` becomes route `/$sport/dashboard`. The router will regenerate `routeTree.gen.ts` on next dev start. + +- [ ] **Step 5.4: Update internal `` and `navigate({to:})` calls** + +Find every internal navigation: +```bash +cd web/src +grep -rEn "to=['\"]/(dashboard|profile|admin|leagues|manage|matches|tournaments|teams|players|courts|match-series|organizations|ref|scorekeeper|quick-match)" --include="*.tsx" --include="*.ts" +``` + +For each match, change: +```tsx + +navigate({ to: '/profile' }) → navigate({ to: '/$sport/profile', params: { sport: currentSlug } }) +``` + +`currentSlug` comes from `useSport()` (already in context for any component under a `$sport` route). + +**Component pattern** for components used in many places: +```tsx +import { useSport } from '../auth/useSport' +const { sport } = useSport() +const sportSlug = sport?.slug ?? '' +// ... +Dashboard +``` + +This is mechanical but tedious. Plan to spend ~2 hours on it. Verify by running `pnpm dev` after each batch of files updated and clicking through the affected pages. + +- [ ] **Step 5.5: Update `__root.tsx` patterns** + +```tsx +// web/src/routes/__root.tsx +const NO_SHELL_ROUTES = ['/', '/auth/callback'] + +const PUBLIC_ROUTE_PATTERNS: RegExp[] = [ + /^\/$/, // sport picker + /^\/auth\//, // OIDC callback + /^\/public(\/|$)/, + /^\/[^/]+\/matches\/[^/]+$/, // /$sport/matches/$publicId is public + /^\/[^/]+\/match-series\/[^/]+$/, + // Old patterns: + // /^\/matches\/[^/]+$/, /^\/match-series\/[^/]+$/, ... +] + +const NO_SHELL_PATTERNS: RegExp[] = [ + /^\/[^/]+\/matches\/[^/]+\/scoreboard$/, + /^\/overlay\/court\/[^/]+$/, + /^\/overlay\/demo\/[^/]+$/, + /^\/tv\/tournaments\/[^/]+$/, + /^\/tv\/courts\/[^/]+$/, +] +``` + +- [ ] **Step 5.6: Verify dev server starts and route tree regenerates** + +```bash +cd web && pnpm dev +``` +Open http://localhost:5173 — expect a 404 (sport picker doesn't exist yet — Task 6) but the route tree should compile cleanly. + +- [ ] **Step 5.7: Commit** + +```bash +git add -A +git commit -m "feat(routing): move sport-scoped routes under /\$sport/* segment + +Restructures ~48 routes under the new /\$sport/* path. Adds a layout +route (\$sport.tsx) that: +- reads slug from URL +- wraps subtree in +- bounces to / if slug is unknown + +Public routes (/public/*) and OBS overlay routes (/overlay/*) stay at +root. Deleted: routes/dashboard.tsx (replaced by /$sport/dashboard), +routes/profile.tsx (replaced by /$sport/profile + Task 8 rewrite). + +Internal navigations updated: ~120 attrs and ~28 +navigate({to:}) calls now use { to: '/\$sport/...', params: {sport: slug} }." +``` + +--- + +## Task 6 — Sport picker landing page + +**Files:** +- Create: `web/src/routes/index.tsx` (replaces deleted dashboard redirect) +- Modify: `web/src/components/Sidebar.tsx` to add a "switch sport" link + +- [ ] **Step 6.1: Build the picker** + +```tsx +// web/src/routes/index.tsx +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { listSports, type Sport } from '../lib/sports' +import { useAuth } from '../auth/useAuth' + +export const Route = createFileRoute('/')({ + component: SportPicker, +}) + +function SportPicker() { + const navigate = useNavigate() + const { isAuthenticated, signIn } = useAuth() + const { data: sports, isLoading, isError } = useQuery({ + queryKey: ['sports'], + queryFn: listSports, + }) + + const choose = (s: Sport) => { + if (!isAuthenticated) { + // Sign in scoped to this sport's organization. After callback, + // we'll land on /$sport/dashboard. + void signIn(`/${s.slug}/dashboard`) + return + } + void navigate({ to: '/$sport/dashboard', params: { sport: s.slug } }) + } + + if (isLoading) return
Loading sports…
+ if (isError) return
Failed to load sports
+ if (!sports || sports.length === 0) { + return
No sports configured yet.
+ } + + return ( +
+

Court Command

+

Choose your sport

+
+ {sports.map((s) => ( + + ))} +
+ {!isAuthenticated && ( +

+ You'll be asked to sign in after choosing. +

+ )} +
+ ) +} +``` + +- [ ] **Step 6.2: Add "switch sport" to sidebar** + +In `web/src/components/Sidebar.tsx`, near the user/profile section, add: +```tsx +import { Link } from '@tanstack/react-router' +// ... + + Switch sport + +``` + +- [ ] **Step 6.3: Delete `routes/login.tsx` and `routes/register.tsx`** + +```bash +cd web/src/routes +git rm login.tsx register.tsx +``` + +These pages no longer exist; OIDC handles sign-in/sign-up. + +- [ ] **Step 6.4: Verify dev server** + +```bash +cd web && pnpm dev +``` +- Visit `/`. Should render a picker with two buttons: Pickleball, Demo Sport. +- Click Pickleball without being signed in. Browser redirects to Logto sign-in page (http://localhost:3001/...). +- Sign in as `admin@courtcommand.local` / `TestPass123!`. +- Browser redirects back to `/auth/callback` (Task 7 handles this). + +For now Task 7 isn't done so the callback will 404. That's fine — Task 7 is next. + +- [ ] **Step 6.5: Commit** + +```bash +git add web/src/routes/index.tsx web/src/components/Sidebar.tsx +git rm web/src/routes/login.tsx web/src/routes/register.tsx +git commit -m "feat(picker): sport picker landing page replaces /login + +Renders active sports as buttons. Choosing a sport while not signed in +triggers Logto OIDC sign-in scoped to that sport's organization; +post-callback, user lands on /\$sport/dashboard. Deletes legacy login +and register routes." +``` + +--- + +## Task 7 — OIDC callback route + +**Files:** +- Create: `web/src/routes/auth.callback.tsx` + +- [ ] **Step 7.1: Build the callback handler** + +```tsx +// web/src/routes/auth.callback.tsx +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useLogto } from '@logto/react' +import { useEffect, useState } from 'react' + +export const Route = createFileRoute('/auth/callback')({ + component: AuthCallback, +}) + +function AuthCallback() { + const { handleSignInCallback, isAuthenticated } = useLogto() + const navigate = useNavigate() + const [error, setError] = useState(null) + + useEffect(() => { + // Logto SDK reads the code/state from the URL and exchanges them + // for a token. Call once on mount. + void (async () => { + try { + await handleSignInCallback(window.location.href) + } catch (e) { + setError(e instanceof Error ? e.message : 'Sign-in failed') + } + })() + }, [handleSignInCallback]) + + useEffect(() => { + if (isAuthenticated) { + // postRedirectUri (set when calling signIn) is honored by Logto + // automatically — the callback page only renders briefly. + // If for some reason we end up here without a target, default to /. + void navigate({ to: '/' }) + } + }, [isAuthenticated, navigate]) + + if (error) { + return ( +
+

Sign-in failed

+

{error}

+ +
+ ) + } + return
Completing sign-in…
+} +``` + +- [ ] **Step 7.2: Add `/auth/callback` to `NO_SHELL_ROUTES` in `__root.tsx`** + +(Should already be done in Task 5.5; double-check.) + +- [ ] **Step 7.3: Manual smoke test** + +```bash +# Backend running on :8080 +# Frontend running on :5173 +cd web && pnpm dev +``` +Browser flow: +1. Visit `http://localhost:5173/` +2. Click "Pickleball" +3. Redirected to `http://localhost:3001/sign-in?...` +4. Sign in as admin@courtcommand.local / TestPass123! +5. Redirected to `http://localhost:5173/auth/callback?code=...&state=...` +6. Should see "Completing sign-in…" briefly, then redirect to `/pickleball/dashboard` +7. `/pickleball/dashboard` will 404 because we haven't built the protected dashboard yet — but the auth flow is working if you got here. + +Inspect dev-tools Network tab: first `XHR` to `/api/v1/auth/me` should include `Authorization: Bearer eyJ...` and `X-Sport: pickleball`. + +- [ ] **Step 7.4: Commit** + +```bash +git add web/src/routes/auth.callback.tsx +git commit -m "feat(auth): OIDC callback route + +Calls handleSignInCallback() with the current URL on mount. Logto SDK +parses ?code= and ?state=, exchanges them at the token endpoint, and +fires the postRedirectUri honor. We redirect to / as fallback if no +post-redirect was set." +``` + +--- + +## Task 8 — Profile edit form against `player_profiles` + +**Files:** +- Create: `api/handler/profile.go` — `GET /api/v1/me/profile`, `PATCH /api/v1/me/profile` +- Create: `api/handler/profile_test.go` +- Modify: `api/router/router.go` — mount under RequireJWT +- Create: `web/src/routes/$sport/profile.tsx` + +The `/api/v1/me/*` endpoints take the JWT subject as the user identity (no path param). The handler reads `Claims` from context. + +- [ ] **Step 8.1: Backend handler** + +```go +// api/handler/profile.go +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/db/generated" +) + +type PlayerProfileDTO struct { + UserID int64 `json:"user_id"` + Phone *string `json:"phone,omitempty"` + DuprID *string `json:"dupr_id,omitempty"` + VairID *string `json:"vair_id,omitempty"` + PaddleBrand *string `json:"paddle_brand,omitempty"` + PaddleModel *string `json:"paddle_model,omitempty"` + Gender *string `json:"gender,omitempty"` + Handedness *string `json:"handedness,omitempty"` + DateOfBirth *string `json:"date_of_birth,omitempty"` // YYYY-MM-DD + Bio *string `json:"bio,omitempty"` + StreetAddress *string `json:"street_address,omitempty"` + City *string `json:"city,omitempty"` + StateProvince *string `json:"state_province,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + Country *string `json:"country,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + EmergencyContactName *string `json:"emergency_contact_name,omitempty"` + EmergencyContactPhone *string `json:"emergency_contact_phone,omitempty"` + EmergencyContactRelation *string `json:"emergency_contact_relation,omitempty"` + MedicalNotes *string `json:"medical_notes,omitempty"` + WaiverAcceptedAt *string `json:"waiver_accepted_at,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + IsProfileHidden bool `json:"is_profile_hidden"` +} + +// GetMyProfile returns the player_profiles row for the authenticated user. +// Returns an empty profile (with user_id only) if no row exists yet. +func (h *Handler) GetMyProfile(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusInternalServerError, "internal_error", "missing claims") + return + } + user, err := h.queries.GetUserByLogtoUserID(r.Context(), &claims.Subject) + if err != nil { + writeError(w, http.StatusNotFound, "user_not_found", "user mirror missing") + return + } + profile, err := h.queries.GetPlayerProfileRow(r.Context(), user.ID) + if err != nil { + // Row doesn't exist yet — return defaults. + writeJSON(w, http.StatusOK, map[string]any{"data": PlayerProfileDTO{UserID: user.ID}}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": profileToDTO(profile)}) +} + +// PatchMyProfile upserts the player_profiles row. Every field is optional; +// NULL leaves existing column unchanged (sqlc.narg COALESCE pattern). +func (h *Handler) PatchMyProfile(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusInternalServerError, "internal_error", "missing claims") + return + } + if !claims.HasScope("write:profile") { + writeError(w, http.StatusForbidden, "forbidden", "missing write:profile scope") + return + } + var in PlayerProfileDTO + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", "decode body") + return + } + user, err := h.queries.GetUserByLogtoUserID(r.Context(), &claims.Subject) + if err != nil { + writeError(w, http.StatusNotFound, "user_not_found", "user mirror missing") + return + } + params := dtoToUpsertParams(user.ID, in) + if _, err := h.queries.UpsertPlayerProfile(r.Context(), params); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "upsert profile") + return + } + // Return the freshly-saved row. + profile, err := h.queries.GetPlayerProfileRow(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "reload profile") + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": profileToDTO(profile)}) +} + +func profileToDTO(p generated.PlayerProfile) PlayerProfileDTO { + // Field-by-field mapping. All nullable text fields are *string already. + // Date/timestamp fields are pgtype.Date / pgtype.Timestamptz — convert + // to *string with format "2006-01-02" / RFC3339. + dto := PlayerProfileDTO{ + UserID: p.UserID, + Phone: p.Phone, + DuprID: p.DuprID, + VairID: p.VairID, + PaddleBrand: p.PaddleBrand, + PaddleModel: p.PaddleModel, + Gender: p.Gender, + Handedness: p.Handedness, + Bio: p.Bio, + StreetAddress: p.StreetAddress, + City: p.City, + StateProvince: p.StateProvince, + PostalCode: p.PostalCode, + Country: p.Country, + EmergencyContactName: p.EmergencyContactName, + EmergencyContactPhone: p.EmergencyContactPhone, + EmergencyContactRelation: p.EmergencyContactRelation, + MedicalNotes: p.MedicalNotes, + AvatarURL: p.AvatarURL, + IsProfileHidden: p.IsProfileHidden, + } + if p.DateOfBirth.Valid { + s := p.DateOfBirth.Time.Format("2006-01-02") + dto.DateOfBirth = &s + } + if p.WaiverAcceptedAt.Valid { + s := p.WaiverAcceptedAt.Time.Format("2006-01-02T15:04:05Z07:00") + dto.WaiverAcceptedAt = &s + } + if p.Latitude.Valid { + v := p.Latitude.Float64 + dto.Latitude = &v + } + if p.Longitude.Valid { + v := p.Longitude.Float64 + dto.Longitude = &v + } + return dto +} + +func dtoToUpsertParams(userID int64, in PlayerProfileDTO) generated.UpsertPlayerProfileParams { + // Convert DTO -> sqlc params. NULLs in DTO map to nil pointers + // in params, which the COALESCE() in the query treats as "leave + // existing value alone" on UPDATE. + p := generated.UpsertPlayerProfileParams{ + UserID: userID, + Phone: in.Phone, + DuprID: in.DuprID, + VairID: in.VairID, + PaddleBrand: in.PaddleBrand, + PaddleModel: in.PaddleModel, + Gender: in.Gender, + Handedness: in.Handedness, + Bio: in.Bio, + StreetAddress: in.StreetAddress, + City: in.City, + StateProvince: in.StateProvince, + PostalCode: in.PostalCode, + Country: in.Country, + EmergencyContactName: in.EmergencyContactName, + EmergencyContactPhone: in.EmergencyContactPhone, + EmergencyContactRelation: in.EmergencyContactRelation, + MedicalNotes: in.MedicalNotes, + AvatarURL: in.AvatarURL, + IsProfileHidden: in.IsProfileHidden, + } + // Date / float / timestamp conversions: parse strings, set Valid=true. + // Implementation detail; copy from existing handlers that already + // do pgtype.Date / pgtype.Timestamptz conversions (e.g. handler/users.go + // for date_of_birth on the legacy users table). + return p +} +``` + +- [ ] **Step 8.2: Test** + +Write `api/handler/profile_test.go`: +- `TestGetMyProfile_NoRowReturnsEmpty` — fake queries returns `pgx.ErrNoRows`, handler returns `{user_id: X, is_profile_hidden: false}` +- `TestGetMyProfile_ExistingRow` — fake returns populated profile, handler returns DTO +- `TestPatchMyProfile_RejectsWithoutScope` — claims with no scopes → 403 +- `TestPatchMyProfile_UpsertsAndReturns` — happy path + +Reuse the `runHandler(claims, ...)` test helper if it exists; if not, build one. + +```bash +cd api && go test ./handler/... -run TestGetMyProfile -v +go test ./handler/... -run TestPatchMyProfile -v +``` + +- [ ] **Step 8.3: Mount the routes** + +In `api/router/router.go`: +```go +r.With(middleware.RequireJWT(validator, true), middleware.MirrorUser(...)).Group(func(r chi.Router) { + r.Get("/api/v1/me/profile", h.GetMyProfile) + r.Patch("/api/v1/me/profile", h.PatchMyProfile) +}) +``` + +(`MirrorUser` is built in Task 9. For now, you can scaffold a no-op middleware and replace it in Task 9.) + +- [ ] **Step 8.4: Frontend — profile route** + +```tsx +// web/src/routes/$sport/profile.tsx +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { apiGet, apiPatch } from '../../lib/api' +import { AuthGuard } from '../../features/auth/AuthGuard' + +export const Route = createFileRoute('/$sport/profile')({ + component: () => ( + + + + ), +}) + +interface PlayerProfile { + user_id: number + phone?: string | null + dupr_id?: string | null + vair_id?: string | null + paddle_brand?: string | null + paddle_model?: string | null + gender?: 'male' | 'female' | 'other' | null + handedness?: 'left' | 'right' | 'ambidextrous' | null + date_of_birth?: string | null + bio?: string | null + street_address?: string | null + city?: string | null + state_province?: string | null + postal_code?: string | null + country?: string | null + latitude?: number | null + longitude?: number | null + emergency_contact_name?: string | null + emergency_contact_phone?: string | null + emergency_contact_relation?: string | null + medical_notes?: string | null + waiver_accepted_at?: string | null + avatar_url?: string | null + is_profile_hidden: boolean +} + +function ProfileEdit() { + const qc = useQueryClient() + const { data: profile, isLoading, error } = useQuery({ + queryKey: ['me', 'profile'], + queryFn: () => apiGet('/api/v1/me/profile'), + }) + + const [draft, setDraft] = useState>({}) + const set = (k: K, v: PlayerProfile[K]) => + setDraft((d) => ({ ...d, [k]: v })) + + const mut = useMutation({ + mutationFn: (data: Partial) => + apiPatch('/api/v1/me/profile', data), + onSuccess: (saved) => { + qc.setQueryData(['me', 'profile'], saved) + setDraft({}) + }, + }) + + if (isLoading) return
Loading…
+ if (error) return
Failed to load profile
+ + // Merge profile + draft for form display. + const view: Partial = { ...profile, ...draft } + + return ( +
+

Profile

+ +
+ + set('phone', e.target.value || null)} className="border rounded p-2 w-full" /> + +
+ +
+ + set('dupr_id', e.target.value || null)} className="border rounded p-2 w-full" /> + + + set('vair_id', e.target.value || null)} className="border rounded p-2 w-full" /> + +
+ +
+ + set('paddle_brand', e.target.value || null)} className="border rounded p-2 w-full" /> + + + set('paddle_model', e.target.value || null)} className="border rounded p-2 w-full" /> + +
+ +
+ + + + + + + + set('date_of_birth', e.target.value || null)} className="border rounded p-2 w-full" /> + + +