Skip to content

Logto integration Phases 0-2: setup + JWT foundation + additive schema#4

Open
itkujo wants to merge 95 commits into
mainfrom
feature/logto-integration
Open

Logto integration Phases 0-2: setup + JWT foundation + additive schema#4
itkujo wants to merge 95 commits into
mainfrom
feature/logto-integration

Conversation

@itkujo

@itkujo itkujo commented May 1, 2026

Copy link
Copy Markdown
Collaborator

Logto integration: Phases 0-2 (setup + JWT foundation + additive schema)

Replaces the cookie-session + bcrypt auth with self-hosted Logto. Introduces multi-sport architecture using Logto organizations as the sport boundary.

This PR does NOT enable Logto sign-in yet. It lands the foundation for Phases 3-6 to build on. Cookie sessions still work exactly as before for every existing route.

What's included

Phase 0 — Logto setup & operator docs

  • docs/LOGTO_SETUP.md — operator walkthrough covering the SPA app, API resource (12 scopes), M2M Management API app, organization template (5 roles + 5 scopes), Pickleball + Demo Sport orgs, bootstrap admin user, and webhook registration. Captures the gotcha that self-hosted Logto's Management API resource indicator is the fixed value https://default.logto.app/api, NOT the public Logto URL.
  • docs/DATABASE_OWNERSHIP.md — codifies the three ownership zones (DB canonical / Logto canonical / bridge tables) and the rule for when migrations are required vs Logto admin changes.
  • .env.example updated with all LOGTO_* and VITE_LOGTO_* variable names.

Phase 1 — Backend JWT foundation

New packages, all 100% locally testable (no Postgres needed):

  • api/auth/ — JWT validator with JWKS caching (1h TTL, 10s fetch timeout, mutex-coalesced refresh, stale-fallback on issuer outage). Claims struct with HasScope / HasOrgRole. ErrJWKSUnavailable sentinel for infrastructure failures vs token failures. Private context key for WithClaims / ClaimsFromContext.
  • api/logto/ — Management API client with cached M2M token (10s safety margin). GetUser, APIError typed for branching on status. livesmoke_test.go opt-in test (LOGTO_LIVE_SMOKE=1) verifies end-to-end against the real Logto deployment.
  • api/middleware/jwt_middleware.go — chi-compatible RequireJWT(validator, orgScoped) middleware. Branches infrastructure failures (503) from invalid tokens (401). Tests cover signature forgery, expired tokens, wrong issuer/audience, malformed/lowercase headers, JWKS-unreachable.
  • api/middleware/sport_middleware.goSportResolver (slug → Logto org ID) + RequireSportMatchesJWT cross-checks the X-Sport header against the JWT's organization_id claim. Defense-in-depth for sport scoping.

Test coverage: 11 named tests + 14 sub-tests across the three packages, all PASS with -race.

Phase 2 — Database migrations (additive-only)

docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md documents why Phase 2 is additive-only: dropping users.password_hash / users.role / tournament_staff.raw_password would break 78 + 113 + 1 references across 35 files in the cookie-session code path. The drops are deferred to Phase 6 cutover.

api/db/migrations/00041_logto_schema_additive.sql:

  • CREATE sports table seeded with Pickleball (ekup1zyrrxj4) and Demo Sport (7866ex96uk6b) — logto_org_id matches the Logto orgs from setup.
  • CREATE player_profiles 1:1 table with all 25 future-removable user profile columns (phone, dupr_id, paddle, address, etc.). Initially empty; populated by Phase 3 forms.
  • ADD users.logto_user_id TEXT (nullable, partial UNIQUE for non-NULL).
  • ADD sport_id BIGINT REFERENCES sports(id) on tournaments, leagues, organizations, venues, divisions. Backfilled to Pickleball, indexed.
  • ADD api_keys.logto_m2m_app_id TEXT (nullable, partial UNIQUE).

New sqlc queries (with review fixes already applied):

  • sports.sqlListSports, GetSportByID, GetSportBySlug, GetSportByLogtoOrgID
  • player_profiles.sqlGetPlayerProfileRow, UpsertPlayerProfile (sqlc.narg-partial pattern, NULL means "leave existing"), DeletePlayerProfile
  • users.sql (appended) — GetUserByLogtoUserID, SetUserLogtoUserID (non-pointer arg, nil-proof, refuses to overwrite an existing different binding)
  • api_keys.sql (appended) — GetAPIKeyByLogtoM2MAppID (admin/management lookup), GetActiveAPIKeyByLogtoM2MAppID (request-auth hot path with is_active = true)

Migration verified clean against the deployed Coolify Postgres — /api/v1/health returns database: ok post-migration.

Health endpoint build-info

/api/v1/health now reports build.commit and build.built_at (compile-time injected via -ldflags). Coolify (this version) does not expose SOURCE_COMMIT at build time so commit resolves to unknown; built_at alone is sufficient deploy fingerprint.

Breaking changes

None. Every existing route, every existing handler, every existing query continues to work unchanged. The new schema is purely additive. The new middleware packages are not yet wired into the router.

Verification

  • go build ./... — clean
  • go vet ./... — clean
  • go test ./auth/... ./logto/... ./middleware/... -race — all PASS
  • Live deploy on Coolify (built_at=2026-05-01T21:40:02Z) — DB + Redis healthy
  • Live smoke test against real Logto (LOGTO_LIVE_SMOKE=1 go test ./logto/...) — bootstrap admin user resolved successfully

What's still ahead (Phases 3-6)

  • Phase 3 — Frontend Logto SDK install, $sport route tree, apiFetch Bearer + X-Sport injection, OIDC callback, profile-edit form against player_profiles. After this lands, Logto sign-in is testable end-to-end.
  • Phase 4 — Tournament staff + API keys delegated to Logto Management API.
  • Phase 5 — Webhook handler (HMAC-SHA256 signature validation), impersonation, on-demand mirror upsert fallback, test-suite migration to JWTs.
  • Phase 6 — Cutover migration: drop the now-unused columns, impose NOT NULL on logto_user_id and sport_id, wire RequireJWT + RequireSportMatchesJWT into the router, remove api/session/. This is the user-visible switch from cookie sessions to Logto.

Spec & plan references

  • Spec: docs/superpowers/specs/2026-04-20-logto-integration-design.md
  • Phase 0-1 plan: docs/superpowers/plans/2026-04-20-logto-integration.md
  • Phase 2 plan: docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md
  • Audit that triggered the Logto pivot: docs/superpowers/audits/2026-04-20-db-schema-alignment.md

Do NOT merge yet

This PR is for review only. Phases 3-6 will continue on the same feature/logto-integration branch. Merge to main happens after Phase 6 cutover is complete and all 15 spec smoke-test items pass.

Daniel Velez added 30 commits April 29, 2026 01:24
Adds docs/LOGTO_SETUP.md with the step-by-step walkthrough for the
self-hosted Logto deployment at https://logto.courtcommand.app and
its admin UI at https://logto-admin.courtcommand.app.

Covers:
- Court Command Web SPA app
- Court Command API resource (12 scopes)
- Court Command Backend M2M app for the Management API
- Organization template (5 roles, 5 org scopes)
- Pickleball + Demo Sport organizations
- Bootstrap platform_admin user
- Webhook registration with HMAC-SHA256 signing key
- Coolify env var checklist for both api and web services
- curl-based smoke tests
- Re-seed runbook

Also adds the LOGTO_* and VITE_LOGTO_* variable names to .env.example.
Values stay in Coolify, not in Git.

Aligned with the Logto integration spec and the Phase 0 outline of
the plan, but adapted to the prod-only deployment model: no docker-
compose Logto service is added because Logto already runs on the
Coolify host.
…/api

Discovered during live smoke test against the deployed Logto instance:
the Management API resource indicator is a fixed Logto-internal value,
not a tenant-specific URL. Using the public Logto domain returned
oidc.invalid_target. This is consistent with how Logto self-hosted
identifies the built-in management tenant.

Updates .env.example default and adds a callout in LOGTO_SETUP.md
Step 3 explaining the distinction between the (fixed) Management API
resource and the (project-specific) LOGTO_API_RESOURCE.
- Adds github.com/lestrrat-go/jwx/v3 dependency for JWT/JWKS handling
- api/auth/Claims: normalized claim shape (subject, organization_id,
  organization_roles, scopes, audience) with HasScope / HasOrgRole helpers
- api/auth/ExtractClaims: pulls Logto-shaped claims from a parsed jwx token,
  handles organization_roles arriving as []string or []interface{}
- api/auth/Validator: JWKS-cached JWT parser (1h TTL) with issuer + audience
  validation; orgScoped flag accepts tokens with urn:logto:organization:* aud
- Context helpers: WithClaims / ClaimsFromContext (private context key type)
- Unit tests for claim extraction (full org-scoped token + minimal token)

go mod tidy promoted gorilla/websocket from indirect to direct (it was
already imported in handler code; tidy corrects the classification).

JWT signature validation will be exercised end-to-end by middleware tests
in the next task.
Tightens api/auth in response to the code-quality review of c5fb841,
in preparation for the JWT middleware (Task 1.4) consuming this API:

- I-2: JWKS fetch wrapped in 10s context timeout so a hung issuer
  cannot stall the validator mutex indefinitely
- M-2: Validate now returns (Claims, error) instead of (jwt.Token,
  error) so middleware doesn't need to import jwx types
- S-3: Added sub-tests for ExtractClaims covering the []interface{}
  organization_roles branch (the production wire-decoded shape) plus
  the non-slice and nil cases that toStringSlice silently handles
- S-7: jwksCacheTTL moved from package const to Validator.keyTTL
  field with a SetKeyTTL setter so middleware tests in 1.4 can
  force JWKS refresh without sleeping for an hour

No public API breakage outside api/auth (no callers yet). The deferred
review item (verify jwt.WithValidate(true) actually rejects expired
tokens) will be covered explicitly by an integration test in Task 1.4.
After the Logto integration splits identity ownership from domain
ownership, 'do I need a migration?' is no longer a simple yes/no.
This doc captures the three zones (Zone A: DB canonical, Zone B:
Logto canonical, Zone C: bridge tables), the decision tree for
when to write a goose migration, and worked examples covering the
common cases.

The rule of thumb is unchanged for everything that was always in
the database: schema changes need migrations, day-to-day data does
not. The new wrinkle is that identity-shaped concepts (roles,
scopes, orgs, user identity) now live in Logto admin and don't
generate migrations.
Adds api/logto package wrapping the self-hosted Logto Management API.
Phase 1 surface only -- read-side methods needed to support /auth/me
upserts and webhook idempotency. Write-side (CreateUser, M2M apps,
organization assignment, impersonation) deferred to Phases 4/5.

- logto.Client: HTTP wrapper around https://logto.courtcommand.app
- logto.Config: Endpoint, M2M app credentials, ManagementAPIResource
  (the fixed-value https://default.logto.app/api audience for self-
  hosted Logto -- documented in docs/LOGTO_SETUP.md Step 3)
- GetManagementToken: OAuth2 client_credentials with mutex-protected
  in-memory token cache; expires_in - 10s safety margin; transparent
  refresh on next call after expiry
- doJSON (unexported): authenticated Management API request helper
  that decodes JSON into a destination value and returns *APIError
  on non-2xx
- logto.APIError: typed error with Status + Body for callers that
  want to branch on status (e.g. 404 -> user not found)
- users.go: LogtoUser struct + GetUser only -- additional user
  methods (Create, Delete, suspension) deferred to Phase 4 per the
  YAGNI option chosen for this task

Tests use httptest fake servers in-process:
- TestClient_GetManagementToken_CachesBetweenCalls (single mint)
- TestClient_GetManagementToken_RefreshesAfterExpiry (refresh after
  expires_in=1s + 10s safety margin)
- TestClient_GetUser_ReturnsAPIErrorOn404 (errors.As unwrap path)

go test ./logto/... -v -race PASS
The dominant error-message style across api/service, api/session, and
api/handler is bare-prefix (e.g. 'checking email: %w'). The api/auth
package was an outlier with 'auth: ...' prefixes. Bringing it into
line so wrapped error chains read consistently.

Behavior unchanged; tests still PASS.
Gated behind LOGTO_LIVE_SMOKE=1 so it never runs in CI / regular
'go test ./...' invocations. When opt-in, it verifies end-to-end:
- M2M token mints via OAuth2 client_credentials
- Token cached on second call
- GetUser resolves a known user by ID
- GetUser returns *APIError with Status=404 for unknown IDs

Source env from .env (gitignored) and pass LOGTO_BOOTSTRAP_USER_ID
matching the user created in Step 6 of docs/LOGTO_SETUP.md:

  set -a; source .env; set +a
  LOGTO_LIVE_SMOKE=1 LOGTO_BOOTSTRAP_USER_ID=<id> go test ./logto/...

Verified passing against https://logto.courtcommand.app on this
branch:

  bootstrap user resolved: id=v3hqe8jx4wnn
  email=daniel.f.velez@gmail.com name=Daniel Velez
  --- PASS: TestLiveSmoke_GetUser (0.41s)
…uest

Adds a chi-compatible middleware that validates the Authorization
Bearer token against an *auth.Validator and stores the parsed
auth.Claims in the request context for downstream handlers. On any
validation failure the middleware writes the standard
{"error":{"code":"unauthorized",...}} envelope (matching the existing
RequireAuth style) and short-circuits the chain.

Behavior:
- Missing / malformed Authorization header  -> 401
- Invalid signature                          -> 401
- Wrong issuer                               -> 401
- Wrong audience                             -> 401
- Expired token (exp in past)                -> 401
- urn:logto:organization:* aud when orgScoped=false -> 401
- Valid token, global aud or org URN (with orgScoped=true) -> next.ServeHTTP

Tests use an in-process JWKS server backed by an RSA keypair so the
full parse path -- signature verification, issuer check, audience
check, expiry validation -- runs end-to-end without any network
dependency. The bad-signature case signs with a second keypair to
prove rejection of forged tokens. The expired-token case explicitly
verifies the I-1 deferred check from the previous code review:
jwt.WithValidate (v3 default) DOES reject tokens with past exp.

Sport-validation middleware (X-Sport header vs JWT organization_id)
arrives in the next task.
Three small follow-ups from the code-quality review of 784af8b:

- M-2: removed dead 'name(label, b)' helper with no-op
  strings.Replace; inlined as fmt.Sprintf at the only call site.
  Drops the 'strings' import.
- M-3: removed stale 'nbf' mention from mintToken doc comment
  (nbf is never set or tested).
- M-5: added TestRequireJWT_ValidToken_LowercaseScheme_Accepted
  with sub-cases for 'Bearer' / 'bearer' / 'BEARER' / 'BeArEr',
  guarding against a future regression of the deliberate
  strings.EqualFold check to strings.HasPrefix. Without this,
  the RFC 6750 case-insensitive scheme behavior was untested.

All 10 named tests + sub-cases PASS with -race.
…org_id

Adds a chi middleware that requires the X-Sport header on every
authenticated request and validates it against the JWT's
organization_id claim. Together with RequireJWT (Task 1.4) this
provides defense-in-depth for sport scoping: the JWT is the
authoritative claim, but the URL/header path is also enforced so a
client navigating between sports cannot accidentally make a request
with a stale token from a different sport.

Components:
- SportResolver: slug -> Logto org ID map populated at app startup
  from LOGTO_PICKLEBALL_ORG_ID / LOGTO_DEMO_SPORT_ORG_ID. Read-only
  after construction; concurrent-safe; constructor copies the input
  map so caller-side mutation cannot affect lookups.
- RequireSportMatchesJWT: middleware that consumes the resolver and
  the auth.Claims placed on context by RequireJWT.

Error responses (matching the existing envelope shape):
  400 - X-Sport header missing
  400 - X-Sport slug unknown to the resolver
  500 - claims missing (programmer error; logged via slog.ErrorContext)
  403 - X-Sport slug's org_id does not match JWT organization_id

Wired into api/router/router.go in Phase 6 cutover, AFTER RequireJWT
on each sport-scoped route group.

Tests cover all four error paths plus the happy path and the
SportResolver's slug lookup including the post-construction-mutation
guard that proves the constructor copy. -race clean.
Three small follow-ups from the Phase 1 cross-package review (verdict
APPROVED WITH SUGGESTIONS):

I-1 (Important): A JWKS-unreachable cold start used to surface as
401 'invalid token', causing operators to chase JWT bugs while the
real failure was networking. auth.ErrJWKSUnavailable is now a typed
sentinel returned (via errors.Is) when getKeySet has no cache to
fall back on. RequireJWT branches on it and returns 503
'service_unavailable' with slog.ErrorContext, so the failure is
loud at the right level. New test
TestRequireJWT_JWKSUnavailable_Returns503 verifies this by pointing
the validator at a closed httptest.Server (refused-connection black
hole).

I-2 (Important): RequireSportMatchesJWT's missing-claims branch was
returning {error:{code:'internal_error', message:'unauthorized'}} --
the message contradicted the 500 status and confused readers. Now
returns message 'server configuration error' which matches the
preceding slog comment ('programmer error: middleware not chained').
Test asserts the new message text.

I-3 (Important): LOGTO_BOOTSTRAP_USER_ID was read by
livesmoke_test.go but undocumented. Added an Optional entry in
.env.example pointing at Step 6 of docs/LOGTO_SETUP.md so future
operators know where the value comes from.

Other findings from the review (cross-package error-code casing
inconsistency M-1, helper pattern drift M-2, etc.) are cosmetic and
deferred to Phase 6 cutover or future polish passes.

Tests: go test ./auth/... ./logto/... ./middleware/... -race  -> all PASS.
Adds a 'build' field to the /api/v1/health response so operators (and
this Logto-integration verification specifically) can confirm which
commit Coolify has deployed without guessing from external symptoms.

Mechanism:
- handler.buildCommit + handler.buildBuiltAt are package-level vars
  defaulting to 'dev' / 'unknown' for local 'go run' workflows.
- api/Dockerfile reads the COMMIT build arg (with COOLIFY_GIT_COMMIT_SHA
  as a fallback) and injects both via -ldflags -X. The build context is
  ./api so .git is unavailable inside the container; ARG/ENV is the
  only reliable injection mechanism.
- docker-compose.yaml passes SOURCE_COMMIT (or COOLIFY_GIT_COMMIT_SHA)
  through to the build arg. Coolify auto-populates these.

After this lands, /api/v1/health returns:

  {
    "status": "ok",
    "services": {"database": "ok", "redis": "ok"},
    "build": {
      "commit": "babf166",
      "built_at": "2026-04-30T23:50:00Z"
    }
  }

Local development continues to show {"commit": "dev", "built_at":
"unknown"} because no -ldflags are applied in plain 'go run'. Test
output unaffected.
The previous attempt added a build-context expansion (./api -> repo root)
and a 'COPY .git' so the Dockerfile could derive the commit SHA itself.
That breaks for git worktrees where .git is a file pointer, complicates
.dockerignore, and increases image-build context size for marginal
benefit.

Simpler approach: rely on Coolify's native SOURCE_COMMIT (or
COOLIFY_GIT_COMMIT_SHA) build-time env vars. docker-compose.yaml's
build.args wires them to the COMMIT ARG, which the Dockerfile injects
via -ldflags -X. When no arg is provided (some local docker builds,
release tarballs), the value falls back to 'unknown'; the BUILT_AT
timestamp still distinguishes builds.

Build context returned to ./api. No .dockerignore changes needed at
the repo root.
Comment-only follow-up to 6b7a7b7. The previous deploy-fix commit
only touched api/Dockerfile + docker-compose.yaml; the latter does
not match the api service's 'api/**' watch path filter in Coolify,
so the deploy did not auto-trigger. This change touches a file
unambiguously under api/handler/ to verify the watch path glob
interprets api/** as expected and to land the build-info
documentation properly.

No code change. The deployed /api/v1/health response should after
this build show:
  build.commit  = <real SHA from SOURCE_COMMIT>  (or 'unknown' if
                  Coolify doesn't pass that arg, in which case
                  we'll widen the watch path or use COMMIT directly)
  build.built_at = ISO timestamp of this build
Coolify (at least the version this project deploys to) does not expose
SOURCE_COMMIT or COOLIFY_GIT_COMMIT_SHA at docker build time, so the
COMMIT arg arrived empty and /api/v1/health reported commit='unknown'
even on fresh deploys.

Fix: change docker-compose.yaml's api build context from ./api to the
repo root, and have api/Dockerfile run 'git rev-parse' against the
.git that Coolify clones into the build artifact directory. The
COMMIT arg still takes precedence so this also works in environments
that do pass it explicitly.

A root-level .dockerignore excludes web/, docs/, and other heavy
non-api paths so the wider build context doesn't bloat the image,
while explicitly preserving .git (which is the whole point).

Local docker build from a git worktree won't resolve the SHA because
.git is a file pointer there, not a directory; that's acceptable
since Coolify is the deploy target and Coolify clones fresh on each
build. Local 'go run' / 'go test' workflows are unaffected; they
fall back to commit='dev'.

This commit also touches docker-compose.yaml at the repo root, which
historically does not match the api/** Coolify watch path. To make
sure this commit deploys, please trigger a manual redeploy in Coolify
once or broaden the watch path to also match api/Dockerfile +
docker-compose.yaml + root .dockerignore.
Comment-only change to api/handler/ which Coolify's api/** watch path
matches, so this deploys the previous commit (34e24a2) which only
touched api/Dockerfile + root files and got stuck because api/** in
Coolify glob behavior matches subdirectories only, not files directly
in api/.

The doc on buildCommit / buildBuiltAt now reflects the two-stage
resolution Coolify-or-build-arg-or-git-rev-parse.
Reverts the Dockerfile + docker-compose changes from 34e24a2. That
attempt expanded the build context to the repo root so the Dockerfile
could read .git for commit-SHA injection, but the resulting build
appears to be failing on Coolify (no new build observed within
~5 minutes of pushing 7275b10 which should have triggered a rebuild
via the api/handler/ watch path). Likely cause: the COPY .git/
instruction is failing because Coolify's build artifact directory
does not present .git as a copyable directory at the build context
root.

Reverting to the simpler ./api build context. The build_at timestamp
in /api/v1/health remains a reliable deploy fingerprint -- pair it
with 'git log --oneline -1' to identify the deployed commit by time.

If/when Coolify exposes SOURCE_COMMIT at build time (or another
mechanism becomes available), the COMMIT build arg will pick it up
automatically without further changes.

Local 'go run' / 'go test' continue to show commit='dev'.
Comment-only follow-up that lives inside api/handler/ so Coolify's
api/** watch path picks it up and triggers a rebuild including the
preceding revert (970324e). Without this trigger that revert sits
un-deployed because api/Dockerfile + docker-compose.yaml + .dockerignore
do not match the watch glob.
The original Phase 2 outline in 2026-04-20-logto-integration.md called
for a single migration that both ADDS new schema (sports, sport_id,
player_profiles, logto_user_id, logto_m2m_app_id) and DROPS old
columns (password_hash, role, raw_password, key_hash etc.). Auditing
the codebase found 78 references to password_hash, 113 to users.role,
and 35 files importing api/session, so 'big-bang Phase 2' would force
rewriting every handler in the same PR as the schema change.

This expanded plan ships Phase 2 as ADDITIVE-ONLY. The migration
00041_logto_schema_additive.sql adds new shape, backfills sport_id
to Pickleball, and never drops anything. The cookie-session code path
keeps working unchanged. The drops happen in Phase 6 cutover after
all Go callers have stopped reading the to-be-removed columns.

Plan covers Tasks 2.1-2.4:
- 2.1 migration SQL (full content in code blocks)
- 2.2 sqlc queries for sports + player_profiles, plus additive
  Logto-aware lookups on users/api_keys
- 2.3 sqlc regenerate + build verification
- 2.4 migration up/down smoke (local Postgres preferred, Coolify
  fallback)

Risk register at the bottom captures the deferred drops, the
hardcoded sport-org IDs, and Phase 3's expectation that
player_profiles can be sparsely populated.
Implements Tasks 2.1-2.3 of the Phase 2 plan
(docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md).

Migration 00041_logto_schema_additive.sql adds:
- sports lookup table seeded with Pickleball + Demo Sport (logto_org_id
  matches the orgs created in Logto admin per LOGTO_SETUP.md Step 5)
- users.logto_user_id (TEXT, nullable, partial UNIQUE index for the
  populated subset)
- player_profiles 1:1 table holding all the future-removable user
  profile columns (phone, dupr_id, paddle, address, etc.)
- sport_id columns on tournaments/leagues/organizations/venues/divisions
  (nullable, backfilled to Pickleball, indexed). Phase 6 cutover
  imposes NOT NULL after every Go writer is updated.
- api_keys.logto_m2m_app_id (TEXT, nullable, partial UNIQUE)

Nothing is dropped: password_hash, role, raw_password, key_hash, etc.
all stay because dropping them would break 78 + 113 + 35 references in
the cookie-session code path that's still in use until Phase 6.

New sqlc queries:
- sports.sql: ListSports, GetSportByID, GetSportBySlug,
  GetSportByLogtoOrgID
- player_profiles.sql: GetPlayerProfileRow (suffixed because the legacy
  players.sql still has GetPlayerProfile pointing at users),
  UpsertPlayerProfile (full-row), DeletePlayerProfile
- users.sql: appended GetUserByLogtoUserID, SetUserLogtoUserID
- api_keys.sql: appended GetAPIKeyByLogtoM2MAppID, SetAPIKeyLogtoM2MAppID

Generated bindings:
- new db/generated/sports.sql.go and player_profiles.sql.go
- updated User struct: +LogtoUserID *string
- updated ApiKey struct: +LogtoM2mAppID *string
- new Sport and PlayerProfile structs in db/generated/models.go
- All other generated files churn only on the sqlc version-comment
  bump (v1.31.0 -> v1.31.1)

Verified locally:
  go build ./...                               # exit 0
  go vet ./...                                 # exit 0
  go test ./auth/... ./logto/... ./middleware/... -race  # all PASS

Migration up/down smoke test against a real Postgres deferred to
Task 2.4. The migration runs automatically on Coolify's next deploy
of api (db.RunMigrations is called from main.go on startup), which
acts as the production smoke.
Comment-only follow-up to ab54799 to force Coolify to pick up the
preceding commit. The api/** watch path appears not to trigger on
files in api/db/ subdirectories despite matching the glob -- pushing
a change inside api/handler/ as the unambiguous trigger.
Migration 00041 used a DO $$ ... END $$ block to capture the
Pickleball sport_id once and reuse it across five UPDATE statements.
Goose's default SQL parser splits on semicolons and runs each piece
separately, which fails on the internal BEGIN/DECLARE/SELECT INTO
keywords because they only have meaning inside the PLPGSQL block.
Goose offers '-- +goose StatementBegin' / 'StatementEnd' markers to
opt out of splitting, but the simpler fix is to not use a DO block
at all -- inline the (SELECT id FROM sports WHERE slug='pickleball')
into each UPDATE. Backfill cost is identical (5 cheap subselects
against a 2-row table at migration time, only runs once).

This is the most likely reason the deploy of ab54799 stalled: the
new container started, ran migrations, hit this error on 00041, and
its health check failed -- so Coolify kept the old container running
and the live built_at never advanced. With the DO block removed,
goose should apply 00041 cleanly on the next deploy.
Three concrete query-shape fixes from the Phase 2 cross-package review,
each closing an interface foot-gun that the next phase's caller code
would otherwise inherit:

I-1 (Important): GetAPIKeyByLogtoM2MAppID was inconsistent with the
legacy GetApiKeyByHash because it didn't filter on is_active = true.
Phase 4's request-auth path needs the active-only variant; admin
tools (deactivation flows, audit lookups) need the unfiltered
variant. Split into two queries with a comment block above each
explaining the semantics:
  - GetAPIKeyByLogtoM2MAppID -- admin/management lookup
  - GetActiveAPIKeyByLogtoM2MAppID -- request-auth hot path

I-2 (Important): SetUserLogtoUserID accepted a *string and would
silently UNSET a binding if a buggy webhook handler passed nil.
Tightened in two ways:
  - The param is now a non-pointer string via @logto_user_id::TEXT,
    so sqlc emits 'string' not '*string' and the type system makes
    it impossible to pass nil.
  - The WHERE clause now requires the existing logto_user_id to be
    NULL or equal to the incoming value -- attempting to overwrite
    an existing different binding returns 0 rows, which the caller
    will surface as 409. The partial UNIQUE index continues to
    prevent two users sharing a logto_user_id at the row level.
Future need to clear a binding (e.g. account deletion) gets a
separate ClearUserLogtoUserID query.

I-3 (Important): UpsertPlayerProfile previously overwrote all 25
columns on conflict using EXCLUDED.col. The Phase 3 profile-edit
form will send a partial payload; the old shape would wipe out
phone/dupr_id/paddle/etc. when a user edited only their bio.
Rewrote to a sqlc.narg-based partial pattern matching the existing
players.sql:UpdatePlayerProfile convention -- NULL nargs leave the
existing column unchanged via COALESCE. INSERT path treats NULL as
unset; is_profile_hidden defaults to false.

Generated bindings regenerated. The new UpsertPlayerProfileParams
struct now has all 25 fields as nullable pointers / pgtype values
and SetUserLogtoUserIDParams.LogtoUserID is a non-pointer string.

I-4 (sport_id backfill defensive guard) deferred to the Phase 6
cutover plan -- belongs in the NOT NULL conversion migration, not
this one.

Local verification:
  go build ./...                              # exit 0
  go vet ./...                                # exit 0
  go test ./auth/... ./logto/... ./middleware/... -race  # all PASS
Local-dev tooling so the full stack (postgres + redis + logto + native
go backend + native vite frontend) runs on the developer's machine,
isolated from production.

New files:
- docker-compose.dev.yml: standalone stack of postgres 17 + redis 7 +
  logto 1.22.0 with host port bindings (5432, 6379, 3001 OIDC, 3002
  admin UI). Postgres seeds an additional 'logto' database via
  POSTGRES_MULTIPLE_DATABASES + the new init script. Logto gets
  host.docker.internal -> host-gateway so its webhook deliveries
  can reach the natively-run Go backend.
- scripts/postgres-init/10-create-additional-databases.sh: idempotent
  init script (community Postgres-image pattern) that creates each
  database listed in POSTGRES_MULTIPLE_DATABASES.
- api/cmd/logto-seed/main.go: idempotent provisioner that mirrors the
  production Logto config in docs/LOGTO_SETUP.md. Creates the SPA app,
  Court Command API resource + 12 scopes, organization template
  (5 roles + 5 scopes + role-scope mappings), Pickleball + Demo Sport
  organizations, bootstrap admin user with platform_admin in both,
  and the User.Created/User.Data.Updated/User.Deleted webhook. Every
  step is preceded by a Find* call so re-running is a no-op.
- api/logto/applications.go: ListApplications, FindApplicationByName,
  CreateApplication, AssignApplicationRoles, ListRoles
- api/logto/resources.go: CreateResource, ListResources,
  FindResourceByIndicator, CreateResourceScope, ListResourceScopes
- api/logto/organizations.go: CreateOrganization, ListOrganizations,
  FindOrgByName, AddUserToOrganization,
  AssignOrganizationRolesToUser, organization roles + scopes
  CRUD, AssignScopesToOrgRole, ListOrgRoleScopes
- api/logto/hooks.go: CreateHook, ListHooks, FindHookByName
- api/logto/users.go: appended CreateUser + FindUserByEmail (with
  fallback to broad scan for older Logto versions that don't honor
  search.primaryEmail)
- docs/LOCAL_DEV.md: full first-run walkthrough including the one
  manual step (operator creates the M2M app via admin UI to bootstrap
  the seeder's credentials; everything else is automated).
- Makefile: dev-up, dev-down, dev-logs, dev-reset, logto-seed targets.

Modified .env.example to make local-dev values the default (so 'cp
.env.example .env' followed by 'make dev-up && make logto-seed'
works out of the box). Production overrides moved to a documented
block at the bottom.

The legacy 'docker-compose.yaml' (Coolify shape) and the existing
docker-compose.local.yaml override are unchanged. The new
docker-compose.dev.yml is standalone: 'docker compose -f
docker-compose.dev.yml up -d' brings up exactly the local-dev stack
without interacting with the prod compose definitions.

Verified locally:
  go build ./...                                       # exit 0
  go vet ./...                                         # exit 0
  go test ./auth/... ./logto/... ./middleware/... -race  # all PASS

Runtime verification (docker compose up + make logto-seed) requires
Docker on the developer machine; documented in docs/LOCAL_DEV.md
prerequisites.
Three fixes after first-run testing:

1. Add npm run cli db seed --swe entrypoint. Without this, Logto's tables
   never get created and the container crash-loops trying to query a
   missing schema.

2. Replace bogus DATABASE_STATEMENT_TIMEOUT (not a real Logto env var)
   with DATABASE_CONNECTION_TIMEOUT=30000. Logto's default 5s connection
   timeout is too tight for slonik's first pool connection during boot.

3. Fix the healthcheck path: OIDC discovery is at
   /oidc/.well-known/openid-configuration, not /api/.well-known/...

Verified locally: all three containers reach (healthy) within 60s of
docker compose up.
Two seeder bugs found during first end-to-end run:

1. Logto's koa-pagination middleware caps page_size at 100. The seeder
   was sending page_size=200 in five places (resources, scopes, orgs,
   org roles, org scopes, role-scopes, applications), which Logto
   rejected with HTTP 400 'guard.invalid_pagination'. We will never
   have >100 of any of these in a fresh tenant; 100 is plenty.

2. LOGTO_BOOTSTRAP_NAME=Local Admin was unquoted in .env.example, which
   the Makefile's 'set -a; . .env' loader chokes on (treats 'Admin' as
   a command). Quoted it: LOGTO_BOOTSTRAP_NAME="Local Admin".

Verified end-to-end: seeder now provisions all 12 API scopes, SPA app,
M2M role, 5 org scopes, 5 org roles with bindings, both sport orgs,
bootstrap admin, and webhook -- all idempotent.
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). Includes UserScope.Organizations
so future getAccessToken(resource, orgID) calls return organization-
scoped tokens (required by RequireSportMatchesJWT in the backend).

No behavior change yet -- the provider wraps the app but no component
consumes useLogto.
3355-line plan covering the 10 Phase 3 tasks (frontend Logto swap,
multi-sport routing, profile-edit form, webhook + on-demand mirror,
Playwright smoke test).

Includes Plan Amendments section (A1-A9) addressing 8 critical issues
found by plan-doc reviewer:
- C1: handler architecture (per-domain XHandler + service.XService,
  not unified Handler+queries)
- C2: avoid overlap with existing /api/v1/players/me; new endpoint at
  /api/v1/me/profile reads player_profiles
- C3: signIn() takes only redirectUri; postRedirectUri persistence
  via sessionStorage
- C4: getAccessToken(resource, orgID) for org-scoped tokens; requires
  UserScope.Organizations in config
- C5: webhook header is logto-signature-sha-256 (no X- prefix)
- C6: full SQL for new sqlc queries (CreateUserFromLogto,
  UpdateUserFromLogto, SoftDeleteUserByLogtoUserID)
- C7: PlayerProfile schema (address_line_1/2, no street_address, no
  emergency_contact_relation, AvatarUrl not AvatarURL)
- C8: route restructure includes venues/ and settings/

Plus important findings I1-I9 (useHandleSignInCallback, race in
SportProvider, 403 probe in SportGuard, file location).
useAuth() now wraps useLogto() from @logto/react. signIn() stashes
the post-auth redirect target in sessionStorage; the OIDC callback
route (Task 7) reads + clears it. signOut() calls Logto's signOut
with a redirectUri of <origin><returnTo>.

The local 'users' mirror is still loaded from /api/v1/auth/me and
exposed under the same User shape (date_of_birth made nullable to
match Phase 2 schema), so existing User-typed callers don't change.

AuthGuard rewritten to fire signIn() instead of pushing to /login.

Imports updated across 25 files; the legacy useLogout shim is
replaced by 'const { signOut } = useAuth(); signOut("/")'.
useLogin and useRegister callers (routes/login.tsx, register.tsx)
are stubbed pending deletion in Task 6.
Daniel Velez and others added 30 commits May 6, 2026 00:48
backup-full now also dumps the Logto identity database alongside
the app database. Detects the db_logto service via 'docker compose
ps -q db_logto' and skips silently if absent (so dev environments
running docker-compose.dev.yml \u2014 which uses a single db with
multiple databases \u2014 still work).

backup-list lists both db-*.sql and db_logto-*.sql separately so
operators can see at a glance whether identity backups exist.
Coolify's compose template parser doesn't handle nested defaults like
${SOURCE_COMMIT:-${COOLIFY_GIT_COMMIT_SHA:-}}. It bails with
'Invalid template: "${COOLIFY_GIT_COMMIT_SHA:-"' before docker
compose even starts.

Both env vars are unset in Coolify (this version doesn't expose
either at build time -- noted in the existing comment), so the
nesting was always going to resolve to empty. Replace with the
literal 'unknown' which the Dockerfile already uses as its fallback.
The /api/v1/health endpoint still reports a reliable built_at
timestamp; commit will just always be 'unknown' until Coolify
exposes the SHA.
Coolify's reverse proxy needs the magic SERVICE_FQDN_<NAME>_<PORT>
env var inside each service's environment: block to know which
hostname routes to which container:port. Without it, the proxy has
no labels for the deployed services and returns 503 'no available
server' for every domain.

Mappings:
- api          :8080  -> SERVICE_FQDN_API_8080         -> api.courtcommand.app
- web          :80    -> SERVICE_FQDN_WEB_80           -> courtcommand.app
- logto        :3001  -> SERVICE_FQDN_LOGTO_3001       -> logto.courtcommand.app
- logto        :3002  -> SERVICE_FQDN_LOGTOADMIN_3002  -> logto-admin.courtcommand.app
- ghost        :2368  -> SERVICE_FQDN_GHOST_2368       -> news.courtcommand.app

Each var defaults to the production domain via ${COOLIFY_VAR:-default}
so a fresh Coolify deploy works without needing to manually set every
SERVICE_FQDN_* in the env tab.

The two-port logto service uses a synthetic NAME 'LOGTOADMIN' for
its admin port -- Coolify allows arbitrary IDENTIFIER values, they
just need to be unique within the resource. Port 3002 routes to the
same container as 3001 but on a different FQDN.
The previous mapping referenced ${SERVICE_FQDN_API:-default}, but
Coolify auto-creates the un-suffixed SERVICE_FQDN_API as an empty
string when it sees the compose file. The :- default only fires
when the env var is UNSET, not when it's empty, so we resolved to
'' and the proxy had no FQDN to register.

Look up the port-suffixed key directly (SERVICE_FQDN_API_8080 etc.)
which the Coolify env tab now sets explicitly to the production URLs.
Same pattern for web/logto/logto-admin/ghost.
…eploy

The Coolify Terminal UI 404'd, so we can't manually exec into the api
container to run logto-seed. Adding it as a docker-compose service that
fires on every deploy is the cleanest path:

- Same build context as api (./api/Dockerfile produces both court-command
  and logto-seed binaries)
- entrypoint: ./logto-seed (overrides the default ./court-command)
- restart: 'no' -- one-shot per deploy
- depends_on: db + logto (healthy) -- both must be reachable for find-or-create
- All Logto + SMTP env vars wired from Coolify env tab
- LOGTO_BOOTSTRAP_PASSWORD added separately to Coolify env (it is a
  bootstrap-only secret; not desirable as a permanent runtime var
  for api/web)

Idempotent -- safe to run on every deploy. The seeder finishes in
<10s when there's nothing new to do. After the first successful run,
the deploy log will contain the printed VITE_LOGTO_APP_ID and
LOGTO_WEBHOOK_SIGNING_KEY which we PATCH into Coolify env for the
final production redeploy that bakes them in.
The one-shot logto-seed bootstrap was running on every deploy, adding
~10s per redeploy and rebuilding/wiring an unnecessary container. The
seeder is fully idempotent so this was harmless, but unnecessary.

Adds 'profiles: ["seed"]' so the service is excluded from the default
'docker compose up' (which Coolify uses). To re-seed manually:

  docker compose --profile seed up seed --abort-on-container-exit

In Coolify, invoke './logto-seed' via the Terminal UI on the api
container -- the binary is baked alongside court-command in the api
image so no separate container is needed for ad-hoc re-seeds.

Verified: 'docker compose config --services' lists 7 services
(no seed); 'docker compose --profile seed config --services' lists 8.
The JWTSession + OptionalJWT bridges already promote the SPA caller
to platform_admin when the Logto token's organization_roles claim
includes it -- so backend handler-level checks (RequirePlatformAdmin
etc.) work correctly. But the /api/v1/auth/me endpoint reads
user.Role straight from the local DB, where CreateUserFromLogto
defaulted everyone to 'player'. Result: the SPA's useAuth hook saw
role='player' even for platform_admins and hid the Admin nav,
Scoring tools, and Broadcast tools.

Promote the elevation logic from a private middleware helper to a
auth.Claims.ElevatedRole() method so MeJWT can reuse it. Apply it
in MeJWT after fetching the local users row. Mirrors what the
bridge does for downstream handler context.

Phase 4+ webhook role-mapping will sync this back into the local
users.role column on org-role-changed events -- at which point this
in-flight override becomes a no-op.

Verified: bridge + optional middleware tests still pass; handler
tests still pass.
… downgrade

Logto generates random organization IDs at creation time, so the IDs
hardcoded in migration 00041 (Pickleball='ekup1zyrrxj4',
Demo Sport='7866ex96uk6b') are stale on every fresh tenant. When the SPA
requests an org-scoped token via getAccessToken(resource, sport.logto_org_id)
with a non-existent org ID, Logto silently issues a resource-only token
instead of failing. The api's claims.ElevatedRole() then sees no
organization_roles claim, never elevates the user to platform_admin, and
the admin sidebar link silently disappears -- with no error surfaced
anywhere -- even when the user holds platform_admin in Logto Console.

This change makes that failure mode loud and impossible to ignore:

- New migration 00042 rewrites the stale 00041 IDs to 'pending-seed'
  on any environment that still holds them. Idempotent: only matches
  the original literal values, so seeded environments are untouched.
- New api/startup package: VerifySportsOrgIDs lists Logto orgs via the
  Management API and confirms every active sports.logto_org_id resolves.
  Production: returns an error with all problems concatenated; main.go
  exits non-zero. Development: logs a structured warning per problem
  and continues. No-op when Logto Mgmt API client is nil (existing
  dev-without-Logto pattern).
- main.go wires VerifySportsOrgIDsFromDB right after the Logto client
  is constructed, before the router is built.
- 8 unit tests cover all branches (valid, pending-seed, empty,
  stale-not-in-Logto, multiple-problems, nil-client, no-sports,
  Logto-API-error) using a fake orgLister; no DB or network needed.

Operators with stale IDs in production must run a one-time UPDATE to
the real Logto org ID before deploying, or run the bootstrap seeder
(api/cmd/logto-seed -> syncSportsOrgIDs) which does the same thing.
Before: a fresh deploy required an operator to remember to run
`./logto-seed` from a container terminal. Skipping it left
sports.logto_org_id empty/stale, the SPA received resource-only tokens
with no organization_roles claim, and platform_admin elevation silently
never fired -- the admin sidebar link just disappeared with no error
anywhere.

After: the api invokes the same idempotent provisioning logic on every
startup, so a fresh deploy comes up fully configured with no manual
SQL or seeder runs. The standalone CLI is retained for local dev and
operator debugging but is no longer required.

Refactor:
- New api/logtoseed package: Config, Result, Run(). All 11 seed* steps
  moved verbatim from cmd/logto-seed/main.go with structured slog
  output replacing the CLI's log.Printf calls.
- api/cmd/logto-seed/main.go: 1037 -> ~125 lines. Now a thin wrapper
  that loads env, builds client/pool, calls logtoseed.Run, prints
  summary for the human at the terminal.
- api/main.go: after constructing logtoClient, calls logtoseed.Run.
  Production failures os.Exit(1); dev warns and continues so local
  stacks without M2M creds still come up. No-op when logtoClient is
  nil (Mgmt API creds absent).

Hardening added during the move:
- Postgres advisory lock (pg_advisory_xact_lock) around Run when a DB
  pool is provided. Prevents concurrent api containers from racing on
  the find-or-create steps when scaled >1.
- Drift protection for SPA app ID: if LOGTO_SPA_APP_ID env is set,
  Run refuses to silently regenerate the app on a Logto where it was
  deleted -- that would invalidate every web image baked with the
  old VITE_LOGTO_APP_ID. Same protection for LOGTO_WEBHOOK_SIGNING_KEY.
- Result.SPAAppCreated / WebhookCreated flags surface "you need to
  capture this new value into env" warnings via slog.

Migration 00042 fix:
- Original 'pending-seed' (single literal) violated the UNIQUE
  constraint on sports.logto_org_id when applied to multiple rows.
  Switched to per-slug 'pending-seed:<slug>' values.
- Verifier's IsPendingSeedPlaceholder helper recognizes the prefix.
- Test suite (handler, service) now passes again.

Verification:
- go build ./... clean
- go vet ./... clean
- go test -short ./... all green (handler 12s with real DB, service,
  startup, plus all existing packages)
Two prior commits (fb81fae, b68e940) fixed the api side: sports.logto_org_id
now contains the real Logto org ID, the seeder runs on every boot, and
the bootstrap admin holds platform_admin in the Pickleball org. Verified
end-to-end via the Logto Management API.

But /me kept returning role: "player" and the admin sidebar link still
didn't appear. Root cause is a mount-order race in the SPA:

1. SportProvider wraps the tree and fires listSports().
2. SAME RENDER, useAuth() (called inside AuthGuard / AuthenticatedLayout)
   enables the /me query.
3. apiFetch -> buildHeaders -> getAccessTokenFn(API_RESOURCE, currentOrgID).
   currentOrgID is '' because listSports hasn't resolved yet.
4. Logto SDK silently issues a resource-only token (aud=API resource,
   no organization_id, no organization_roles).
5. api's claims.ElevatedRole() sees no platform_admin and returns "".
6. /me responds role: "player". React Query caches for 5 minutes.
7. SDK also caches the resource-only token in localStorage, so even
   sign-out / sign-in races the same way.

Fix: useAuth now imports useSport() and:
- Gates the /me query enabled flag on !sportLoading so it waits one
  tick for SportProvider to call setCurrentSport with the real orgID.
- Includes sport?.slug in the queryKey so navigating between sports
  refetches /me with the new org's elevation reflected.

Reserved routes (/, /public/*) have sport=null with isLoading=false
after the sports list returns, so /me still fires there -- it just
won't carry an org-scoped token until the user navigates to a sport.
That's correct: platform_admin shows up the moment ANY org-scoped
token is minted, which happens on the first /<sport>/* navigation.

No api changes; web only.
…ars in token

The previous commit (971b11a) gated /me on sport context so the SDK now
correctly requests org-scoped tokens. Verified via decoded JWT: aud is
the API resource and organization_id=vcx906e38a2v is present.

But /me kept returning role: "player" because organization_roles was
ABSENT from the token. Logto's behavior: UserScope.Organizations gets
you organization_id in the token, but you must also request
UserScope.OrganizationRoles to get the array of role names. Without it
the api's claims.ElevatedRole() finds an empty OrganizationRoles slice
and never elevates platform_admin.

Adding UserScope.OrganizationRoles to logtoConfig.scopes fixes this.

Users must sign out + sign in again because the consent grants are
per-scope-set; the SDK won't auto-include a scope it didn't request
during the original authorize.
…Logto roles

While the Logto organization_roles claim plumbing is being debugged
(token currently arrives org-scoped but with empty organization_roles
even after requesting UserScope.OrganizationRoles), this commit removes
the role check at all three enforcement points so admin work can
continue:

  1. api/middleware/auth.go RequirePlatformAdmin -- backend gate
  2. web/src/features/admin/AdminGuard.tsx       -- SPA route guard
  3. web/src/components/Sidebar.tsx              -- admin link visibility

All three sites carry a TEMP-ADMIN-BYPASS marker. Find them with:
  git grep TEMP-ADMIN-BYPASS

DANGER: every authenticated user on courtcommand.app now has full
platform admin access. Public sign-up via Logto is currently open.
Revert before any go-live, before any external user testing, and as
soon as the Logto roles claim is fixed.

The original role checks are preserved verbatim in comments at each
site so the revert is mechanical.
While the TEMP-ADMIN-BYPASS is live in production (every signed-in
user has full admin access until the Logto organization_roles claim
plumbing is fixed), it's been silent: nothing in the UI signals that
admin is wide open. Adds a loud red banner across the top of every
authenticated page so the exposure is impossible to miss on sign-in
and impossible to forget at review time.

- web/src/features/admin/bypass.ts: ADMIN_BYPASS_ACTIVE constant (single
  source of truth for the banner; flipping to false hides it but does
  NOT restore the gates -- those are independent reverts).
- web/src/features/admin/AdminBypassBanner.tsx: sticky red bar, full
  width, white text. Self-hides when ADMIN_BYPASS_ACTIVE is false or
  when not visible (passed by layout). Inline color fallback so a
  Tailwind purge can't accidentally make the banner invisible.
- web/src/routes/__root.tsx: mounts the banner above ImpersonationBanner
  in both AuthenticatedLayout and PublicLayout's authenticated branch.
  Anonymous public visitors don't see it (the bypass doesn't affect
  them -- they have no role to misuse).

Also adds docs/LOGTO_RUNBOOK.md: operational guide covering the auth
flow architecture, api boot sequence (auto-bootstrap + verifier),
known races we've already fixed (mount-order, scope, org-ID drift),
token caching, debugging recipes (JWT decode, scope inspection, Mgmt
API curls), common failure shapes, and the full TEMP-ADMIN-BYPASS
revert checklist with the order-of-operations.

No bypass sites changed in this commit. Banner exists purely as a
safety signal until Logto org-roles plumbing is fixed and the gates
are restored.
…the claim

Root cause (newly understood): Logto by design does NOT include the
organization_roles claim in API-resource access tokens. Per Logto's
own docs, that claim only appears in ID tokens and at the userinfo
endpoint. Earlier fixes in this branch (adding UserScope.OrganizationRoles
to the SDK config, fixing the mount-order race) made the token
correctly org-scoped, but they couldn't put a claim in the token that
Logto never emits on this code path.

The api's claims.ElevatedRole() was reading organization_roles directly
from the JWT, so elevation never fired in production. /me kept
returning role: "player" even though Logto Console showed the user as
platform_admin in the Pickleball org.

This commit closes the gap with a second elevation path:

  - api/logto/organizations.go: new GetUserOrganizationRoles wrapping
    GET /api/organizations/{orgId}/users/{userId}/roles. Returns role
    names (e.g. ["platform_admin"]).

  - api/middleware/org_role_resolver.go: OrgRoleResolver interface +
    LogtoMgmtAPIResolver implementation. Looks up Redis first
    (positive AND negative caching), falls through to Logto on miss,
    writes results back. TTL configurable via
    LOGTO_ORG_ROLES_CACHE_TTL_SECONDS (default 60s). Redis errors
    fall through to Logto rather than failing the request.

  - api/middleware/jwt_session.go: JWTSession middleware now takes an
    OrgRoleResolver and performs a Path-2 lookup when:
      * The JWT fast path (claims.ElevatedRole) returned nothing, AND
      * The token has an organization_id claim, AND
      * The local DB role isn't already platform_admin, AND
      * A resolver is wired (production has one; testutil does not)
    On resolver errors, falls through to the local DB role instead of
    failing the request -- Logto Mgmt API hiccups can't flip the
    bypass-vs-deny posture.

  - api/router/router.go + api/main.go: wire the resolver through.
    main.go constructs a LogtoMgmtAPIResolver against the existing
    Redis client (via sessionStore.Client()) and the existing Logto
    client. Threaded into router.Config.OrgRoles -> authMiddlewares
    chain.

  - Existing TEMP-ADMIN-BYPASS sites stay in place until end-to-end
    verification confirms elevation works in production. Revert
    sequence is documented in docs/LOGTO_RUNBOOK.md section 6.2.

Tests:
  - 6 new OrgRoleResolver tests (cache miss, cache hit, error paths,
    no-Redis fallback, TTL env parsing)
  - 6 new JWTSession Path-2 tests (elevation, no-roles, no-org,
    already-admin, resolver-error, JWT-fast-path-still-works)
  - All existing tests pass (DB-test failures pre-exist as local
    Postgres connectivity issues, not introduced by this change)

Runbook updated: section 3.2 documents the actual Logto behavior;
6.1 marks the open question resolved.
Decomposes the annotated SMOKE_TEST.md run into 17 PRs with a dependency
graph and merge policy. Full scope: bugs, auth/session, UX polish, nav,
ref-console redesign, VAIR, and impersonation.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
postgres:17-alpine has no bash, so the additional-database init script
failed with "env: can't execute 'bash'" and Logto's database was never
created, leaving the dev stack unable to start Logto. The script body is
already POSIX-compatible; only the shebang was wrong.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Quick matches qm1/qm2 were seeded with created_by_user_id set to player
users (p13_id, p11_id). ListQuickMatchesByUser filters on
created_by_user_id = $1, so the bootstrap admin/TD never saw them and the
Quick Match list looked empty. Reassign qm1 -> admin_id and qm2 -> td1_id.

Both API keys were seeded to different users (admin_id, broadcast_id), so
the admin API-keys page showed only 1. ListApiKeysByUser filters on
user_id with no logto_m2m_app_id filter. Reassign the Broadcast API Key
to admin_id so the admin sees 2 (distinct names/prefixes preserved).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…moke 16.3) (#8)

The anonymous/public layout rendered <PublicBottomTabs /> unconditionally,
so the mobile-app-style bottom tab bar appeared on desktop too. The two
authenticated layouts already gate it with `isMobile && <PublicBottomTabs />`.
Apply the same gate to the anonymous layout. isMobile is already in scope via
the existing useIsMobile() hook.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…5) (#9)

A court could be flagged stream_is_live=true with no stream_url, so the
court detail showed a "Live" badge but rendered no video embed.

Client-side guard in CourtEditForm:
- Disable the "Stream is currently live" checkbox (with a hint) while the
  stream URL field is empty.
- Reset streamIsLive to false when the URL is cleared, so no orphaned
  live-without-URL state can persist.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… 8.1) (#10)

VAIR is our preferred rating partner. The players list already shows VAIR
primary with DUPR secondary/muted, but the player detail page and the
profile edit form still listed DUPR first. Reorder both so VAIR appears
first/primary and DUPR second, matching the list. Platform stays
rating-agnostic — DUPR is retained, just demoted to secondary.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ble (#11)

Logto rejects an email/phone sign-up identifier unless verification is
enabled (sign_in_experiences.passwordless_requires_verify). In local dev
there is no SMTP connector, so verify resolved to false and the seeder
crashed on the sign-in-experience step with HTTP 400 -- which also meant
the Logto webhook was never created. Fall back to a username sign-up
identifier when verification is unavailable; the bootstrap admin still
signs in via email+password. Verified: seeder now completes and creates
the webhook + signing key.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… page (smoke 16.10) (#12)

The post_logout_redirect_uri handed to Logto must EXACTLY match a
registered postLogoutRedirectUri or OIDC refuses to redirect and parks
the browser on the raw /oidc/session/end page. The seeder registers the
bare app origin with NO trailing slash (seeder.go trimAuthCallback ->
"http://localhost:5173"), but signOut() was sending origin + returnTo
("http://localhost:5173/") -- a different string under OIDC. For the
root case we now send the bare origin so it matches exactly.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…t-JWT, lock WS origin (PR-01) (#13)

Four independent auth/security hardening fixes:

1. Remove TEMP-ADMIN-BYPASS at all gate sites
   - api/middleware/auth.go RequirePlatformAdmin: restore the 403-unless-platform_admin gate
   - web/src/features/admin/AdminGuard.tsx: restore the platform_admin redirect
   - web/src/components/Sidebar.tsx: only show admin nav to platform_admin
   - web/src/features/admin/bypass.ts: ADMIN_BYPASS_ACTIVE = false (banner + file left in place)

2. Complete org-role -> users.role mapping in api/auth/context.go ElevatedRole()
   - Map all five Logto org-roles (platform_admin, tournament_director, referee,
     scorekeeper, player) to local users.role; highest-privilege role wins,
     platform_admin precedence unchanged; unknown org-roles ignored.

3. Chain RequireSportMatchesJWT on sport-scoped protected routes (api/router/router.go)
   - Built a SportResolver from sports.logto_org_id in main.go and threaded it
     into the router; useAuth now appends the sport check on the JWT path so a
     token minted for one sport org can't act on another sport's URL. Identity
     (/auth/*) and stop-impersonation groups deliberately skip the check.

4. Restrict WebSocket CheckOrigin (api/ws/handler.go)
   - Allow only CORS_ALLOWED_ORIGINS (so OBS/browser-source overlays on the web
     origin still connect) plus empty-Origin non-browser clients; reject others.

Verified: api go build + go vet + auth/middleware/router tests pass;
web pnpm build + typecheck clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…5) (#14)

The referee console had been stripped down to roughly the Scorekeeper
view. A ref needs more: record verbal officiating calls and watch the
full event log alongside scoring.

Frontend:
- VerbalsPanel.tsx — record let / re-do / fault / line-call events with
  optional team attribution, wired to a new useRecordMatchEvent hook
  (POST /matches/{id}/events). These annotate the timeline without
  mutating the score.
- MatchEventLog.tsx — compact, scrollable, newest-first event feed reusing
  the shared useMatchEvents query and EVENT_META icon/label map.
- RefMatchConsole.tsx — ref-specific two-column layout: scoring +
  game-history bar in the main column, VerbalsPanel + MatchEventLog in a
  side panel. Stacks on phone/portrait, splits at xl. Scorekeeper console
  unchanged.

Backend (minimal): the match_events.event_type CHECK accepted legacy
'fault' but not 'let' / 're_do' / 'line_call', so recording those would
500 at the DB. Added the four canonical EventType* constants in
service/events.go (mirrored to the contract test map and the frontend
EventType union), and migration 00043 widening the CHECK. No sqlc regen
needed — the type column is text and RecordEvent passes it through.

Verify: web build + tsc clean; api go build + vet clean; event-type
contract tests pass.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
)

Restore admin impersonation under the JWT auth path using Logto's
first-class OAuth 2.0 Token Exchange (RFC 8693) instead of the dead
legacy cookie flow. Per docs/FEATURES.md §20.

Backend:
- logto: CreateSubjectToken (POST /api/subject-tokens) + PatchApplication.
- seeder: idempotently enable customClientMetadata.allowTokenExchange on
  the SPA app (preserves existing metadata via merge).
- handler: POST /api/v1/admin/users/{userID}/impersonate (platform_admin
  only via the admin group's RequirePlatformAdmin gate) — mints a subject
  token and writes a start_impersonation activity_logs row.
- auth.Claims: parse the act claim (ActorSubject / IsImpersonated).
- MeJWT: surface impersonation from the act claim so the SPA banner renders.
- StopImpersonation: JWT-aware — logs stop_impersonation from the act claim
  (cookie branch retained for testutil only).

Frontend:
- auth/impersonation.ts: token-exchange at Logto /oidc/token, sessionStorage
  for the impersonation token, act-claim decode helper.
- api.ts buildHeaders: prefer the impersonation token when present.
- admin hooks: start = POST backend + exchange + store; stop = audit + clear.
- useAuth/AuthProvider: clear the impersonation token on sign-out / 401.

Legacy cleanup: unmounted the dead cookie /impersonate/{userID} route;
deprecated StartImpersonation + Impersonator* session fields with a
Phase-6 removal TODO (kept additive — cookie store still references them).

Verification: api go build + go vet clean; auth/logto/logtoseed tests pass;
web pnpm build + tsc -b clean. End-to-end impersonation needs a browser —
live-verify pending (master session).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…s PR-17) (#16)

PR-17 added Logto token-exchange impersonation, but the exchanged access
token carried no RFC 8693 `act` claim, so neither the backend
(api/auth/context.go actorSubject) nor the SPA impersonation banner could
detect impersonation.

Part A (backend): ImpersonateUser now adds impersonator_logto_id (the
calling admin's Logto user id, from claims.Subject) to the subject-token
context. The JWT customizer maps it to act.sub, which both actorSubject
and the SPA expect to be a string.

Part B (seeder): add a Logto Mgmt API client method that PUTs the
access-token JWT customizer script (idempotent upsert), and call it as a
new seed step. The script copies the impersonator identity out of the
token-exchange subjectTokenContext into the act claim.

Verified end-to-end against Logto 1.22.0: the exchanged access token now
contains "act":{"sub":"kmwonm5xmyuz",...} with sub as a string.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…es endpoints (PR-19) (#17)

Add four PUBLIC, unauthenticated, read-only endpoints under the existing
/api/v1/public group so a logged-out spectator can view a single tournament
division's detail, bracket, standings, and matches:

- GET /public/divisions/{divisionID}            division detail + parent
                                                 tournament slug/name + counts
- GET /public/divisions/{divisionID}/bracket    division matches w/ bracket
                                                 wiring (mirrors the shape the
                                                 frontend bracket renderer
                                                 consumes from the authed
                                                 /divisions/{id}/matches)
- GET /public/divisions/{divisionID}/standings  standings entries (same shape
                                                 as authed standings GET;
                                                 season resolved from parent
                                                 tournament)
- GET /public/divisions/{divisionID}/matches    division match list

Reuses MatchService.ListByDivision and StandingsService.ListByDivision; no SQL
duplicated. Wires StandingsService into PublicHandler (new SetStandingsService +
main.go). Hides divisions whose parent tournament is draft and returns 404 with
the standard error envelope when a division is not found.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ire division cards (PR-20) (#18)

Adds a public, no-login division detail page so logged-out spectators can
drill into a tournament's division.

- New route /public/divisions/$divisionId (covered by the existing
  /^\/public(\/|$)/ public-layout pattern; no auth guard).
- New PublicDivisionDetail feature component with Overview / Bracket /
  Standings / Matches tabs, mirroring the PublicTournamentDetail patterns
  (TabLayout, Card, skeletons, empty states) and the authed DivisionBracket
  layout (read-only, drills into the public match page).
- New public hooks: usePublicDivision, usePublicDivisionBracket,
  usePublicDivisionStandings, usePublicDivisionMatches (anon /public/* GETs).
- Fixes the dead DivisionCard in PublicTournamentDetail: each card is now a
  real Link to /public/divisions/{id} (it previously had hover + chevron but
  no navigation).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…rdances (PR-21) (#19)

Part A — public match page for anonymous users: verified it already works.
The route /$sport/matches/$publicId is whitelisted public, PublicLayout
renders the anon shell, and MatchDetail's data hooks (useMatch /
useMatchEvents) already hit the no-auth public endpoints
/api/v1/matches/public/{id}{,/events}. useAuth's /me query is disabled
when unauthenticated, the score-override admin block is gated on a logged-in
privileged role, and the match WebSocket connects without a token. Confirmed
the public endpoints return 200 for anonymous requests (with and without the
X-Sport header SportProvider sends), so no code change was needed for Part A.

Part B — court cards: real destinations + no false affordances.
- VenueCourtCard (PublicVenueDetail) and CourtCard (PublicTournamentDetail)
  now drill into the public match page for the match the court is actually
  showing: active_match wins, else on_deck_match. Previously on-deck-only
  courts showed match info with no link at all.
- The whole card is the click target when there's a target match, with a
  coherent affordance (hover accent border + ChevronRight) — matching the
  DivisionCard/MatchRow Link pattern. The inner active-match block is no
  longer a nested anchor.
- A court with no match renders as an honest static info card: no hover
  accent, no chevron, no cursor-pointer. A quiet court stays quiet.
- Sport slug resolves from useSport() (falls back to 'pickleball'), removing
  the hardcoded sport on the drill-in links.

Verify: pnpm install --frozen-lockfile && pnpm build (route tree regen) +
pnpm typecheck both clean. Anon curl of the public match + events endpoints
returns 200.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Captures the beta wrap-up state: 15 PRs merged (#5-19) incl. the public
spectator drill-in feature, security hardening, ref console, and
impersonation. Documents what remains (optional public court page, VAIR
(blocked on docs), Coolify deploy, README refresh) and a Mac pickup
walkthrough (the .env + local Logto tenant do not transfer).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant