feat(auth): implement invite and admin-approval registration policies #321

Merged
james merged 1 commit from 219-registration-policies into main 2026-06-28 18:00:50 +00:00
Owner

Implements the invite and admin-approval REGISTRATION_POLICY values (#219), which were previously accepted but rejected every registration with 503 policy_not_implemented.

Backend

  • Migrations 020_users_status (users.status: active|pending, default active) + 021_invites (sha256 token_hash UNIQUE, created_by cascade / consumed_by set-null). InvitesRepository (consume-once guard on consumed_at IS NULL) + UsersRepository status methods.
  • Policy logic (registration.ts): first user is always allowed as active admin under any policy. open → 201 active. invite → requires a one-time token, consumed only on success (invite_required→403, invite_invalid→400, invite_consumed→409, invite_expired→410). admin-approval → creates a pending user, returns 202 {status:"pending"} with no session.
  • Login gates pending accounts → 403 pending_approval.
  • Admin endpoints under the auth-gated /api/admin/* via a new requireAdmin helper (session or bearer; 401 no creds / 403 forbidden):
Method Path
GET / POST /api/admin/invites (list cursor-paginated / mint, returns plaintext token once)
DELETE /api/admin/invites/{id} (revoke)
GET /api/admin/pending-users
POST /api/admin/pending-users/{id}/approve
DELETE /api/admin/pending-users/{id} (reject = delete; identities cascade)

Contract + client

  • Every new path registered in openapi-routes.ts; root openapi.json regenerated (drift + coverage green, 104 pairs).
  • Regenerated @carol/api-client types; new admin-invites / admin-pending-users hooks.
  • Admin-gated Invites + Pending approvals cards in account.tsx (reveal-token-once + copy, list/revoke, approve/reject); optional invite-code field + awaiting-approval screen in register.tsx; i18n + README REGISTRATION_POLICY row updated.

Verification

  • pnpm -F @carol/api test806 pass / 175 skipped (Postgres legs skip locally without TEST_POSTGRES_URL; CI's matrix runs them — migrations/repos written for both engines).
  • openapi:check up-to-date, openapi:coverage 104 pairs.
  • @carol/api-client + @carol/client typecheck, @carol/client lint — clean.
  • New tests: tests/db/invites.test.ts (both-engine), tests/api/registration-policies.test.ts (invite consume-once; admin-approval 202→403→approve→login; first-user-admin under every policy).

First-user-is-admin semantics preserved under every policy. Email delivery of invites is out of scope per the ticket (token shown to admin to share manually).

Closes #219

🤖 Generated with Claude Code

Implements the `invite` and `admin-approval` `REGISTRATION_POLICY` values (#219), which were previously accepted but rejected every registration with `503 policy_not_implemented`. ## Backend - **Migrations** `020_users_status` (`users.status`: `active`|`pending`, default active) + `021_invites` (sha256 `token_hash` UNIQUE, `created_by` cascade / `consumed_by` set-null). `InvitesRepository` (consume-once guard on `consumed_at IS NULL`) + `UsersRepository` status methods. - **Policy logic** (`registration.ts`): first user is always allowed as **active admin** under any policy. `open` → 201 active. `invite` → requires a one-time token, consumed only on success (`invite_required`→403, `invite_invalid`→400, `invite_consumed`→409, `invite_expired`→410). `admin-approval` → creates a `pending` user, returns **202 `{status:"pending"}` with no session**. - **Login** gates pending accounts → `403 pending_approval`. - **Admin endpoints** under the auth-gated `/api/admin/*` via a new `requireAdmin` helper (session or bearer; 401 no creds / 403 `forbidden`): | Method | Path | |---|---| | GET / POST | `/api/admin/invites` (list cursor-paginated / mint, returns plaintext `token` once) | | DELETE | `/api/admin/invites/{id}` (revoke) | | GET | `/api/admin/pending-users` | | POST | `/api/admin/pending-users/{id}/approve` | | DELETE | `/api/admin/pending-users/{id}` (reject = delete; identities cascade) | ## Contract + client - Every new path registered in `openapi-routes.ts`; root `openapi.json` regenerated (drift + coverage green, 104 pairs). - Regenerated `@carol/api-client` types; new `admin-invites` / `admin-pending-users` hooks. - Admin-gated **Invites** + **Pending approvals** cards in `account.tsx` (reveal-token-once + copy, list/revoke, approve/reject); optional invite-code field + awaiting-approval screen in `register.tsx`; i18n + README `REGISTRATION_POLICY` row updated. ## Verification - `pnpm -F @carol/api test` — **806 pass / 175 skipped** (Postgres legs skip locally without `TEST_POSTGRES_URL`; CI's matrix runs them — migrations/repos written for both engines). - `openapi:check` up-to-date, `openapi:coverage` 104 pairs. - `@carol/api-client` + `@carol/client` typecheck, `@carol/client` lint — clean. - New tests: `tests/db/invites.test.ts` (both-engine), `tests/api/registration-policies.test.ts` (invite consume-once; admin-approval 202→403→approve→login; first-user-admin under every policy). First-user-is-admin semantics preserved under every policy. Email delivery of invites is out of scope per the ticket (token shown to admin to share manually). Closes #219 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): implement invite and admin-approval registration policies
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 12s
PR / OSV-Scanner (pull_request) Successful in 1m22s
PR / Static analysis (pull_request) Successful in 1m50s
PR / OpenAPI (pull_request) Successful in 3m37s
PR / Typecheck (pull_request) Successful in 3m50s
PR / Client (web export smoke) (pull_request) Successful in 3m50s
PR / Build (pull_request) Successful in 4m1s
PR / Lint (pull_request) Successful in 4m9s
PR / Package age policy (soft) (pull_request) Successful in 31s
PR / Trivy (image) (pull_request) Successful in 2m37s
PR / pnpm audit (pull_request) Successful in 4m13s
PR / Test (postgres) (pull_request) Successful in 4m23s
PR / Test (sqlite) (pull_request) Successful in 4m27s
Secrets / gitleaks (pull_request) Successful in 37s
PR / Coverage (soft) (pull_request) Successful in 2m54s
2c49295110
Implements the invite and admin-approval REGISTRATION_POLICY values
(#219), which were previously accepted but rejected every registration
with 503 policy_not_implemented.

Data: migrations 020 (users.status: active|pending) + 021 (invites table,
sha256 token_hash, created_by cascade / consumed_by set-null);
InvitesRepository (consume-once guard) + UsersRepository status methods.

Backend: registration.ts branches on policy — first user is always
allowed as active admin under any policy; invite requires + consumes a
one-time token on success (invite_required/invalid/consumed/expired);
admin-approval creates a pending user and returns 202 with no session.
Login gates pending accounts with 403 pending_approval. Admin-only
endpoints under /api/admin/* via a requireAdmin helper: invites
list/create/revoke and pending-user list/approve/reject.

Contract: every new path registered in openapi-routes; root openapi.json
regenerated (drift + coverage green).

Client: regenerated types; admin-invites/admin-pending-users hooks;
admin-gated Invites + Pending-approvals cards in account.tsx; optional
invite-code field + awaiting-approval screen in register; i18n + README
REGISTRATION_POLICY row updated.

Tested on SQLite (Postgres legs run in CI's matrix).

Closes #219

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 81.7% ≥ 50%
Branches 73.2% ⚠️ ≥ 75%
Functions 91.1% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 81.7% ✅ | ≥ 50% | | Branches | 73.2% ⚠️ | ≥ 75% | | Functions | 91.1% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit f1d8337c7c into main 2026-06-28 18:00:50 +00:00
james deleted branch 219-registration-policies 2026-06-28 18:00:51 +00:00
Sign in to join this conversation.
No description provided.