Roadmap & status
Based on the full codebase audit vs the specifications. Status legend: ✅ done · 🟡 partial · ⬜ planned · 🚫 accepted / won't fix.
Programme — security hardening, BetterAuth 1.7 & quality
| Phase | Scope | Status |
|---|---|---|
| 0 | Lint / format / coverage / CI guardrails | ✅ |
| 1 | Urgent security fixes (fail-closed OAuth guard, admin hierarchy, env booleans, token revocation, M2M verification) | ✅ |
| 2 | BetterAuth 1.6.23 → 1.7.7 (schema, protected resources, M2M E2E) | ✅ |
| 3 | Design-level hardening | ✅ (M-02 / L-08 accepted, L-10 won't-fix) |
| 4 | Refactoring to AGENTS.md rules (core) | ✅ (remainder → Phase 6) |
| 5 | New features + email delivery hardening (optional Redis, DPoP, back-channel logout…) | 🟡 5a done · 5b partial |
| 6 | Quality: types, file split, coverage ≥80 %, specs sync | ⬜ |
| 7 | Loose ends (social providers, role cast, health probe, Dependabot) | ⬜ |
The LiteLLM gateway and shared credit wallet programme is tracked separately in the LiteLLM gateway & credit wallet plan.
The per-product Stripe subscription programme (paid plans with fixed and metered features, delegated billing pages) is tracked in the Stripe billing programme plan.
A phase is considered done only when pnpm build:server, pnpm test,
pnpm test:integration and pnpm lint all pass.
Re-evaluation of the previous audit
Every item from the earlier "missing features / broken / sprint plan" audit is now classified. Resolved = implemented; won't fix = deliberate decision.
| # | Previous finding | Status | Evidence / decision |
|---|---|---|---|
| 1 | Dashboard sessions field mismatch (list vs sessions) | ✅ Resolved | src/routes/admin/sessions.ts returns sessions; DashboardView.vue reads res.sessions |
| 2 | Integration suite missing (vitest.integration.config.ts) | ✅ Resolved | Phase 0 — integration suite (src/tests/*.integration.test.ts, PostgreSQL 17) |
| 3 | ESLint / Prettier / husky / lint-staged missing | ✅ Resolved | Phase 0 (flat config, pre-commit, CI) |
| 4 | Per-user isMfaRequired not enforced | ✅ Resolved | userMustSetupMfa() blocks OAuth token issuance (BetterAuth FORBIDDEN error raised in src/auth.ts; the AUTH_004 registry code is not emitted on this path) |
| 5 | Stripe webhook handler missing | ✅ Resolved | src/routes/stripe-webhook.ts + idempotency ledger |
| 6 | Last-superadmin guard on deletion | ✅ Resolved | Guard existed on delete; Phase 3 added demotion guard (atomic) |
| 7 | /admin/applications/new dedicated route | 🚫 Won't fix | Modal-based create is the accepted UX |
| 8 | User detail shows plan UUID instead of name | ✅ Resolved | Left join on subscriptionPlans.name in users.ts |
| 9 | LinkedIn / Microsoft / Apple providers not wired | ⬜ Phase 7 | config.ts accepts them but auth.ts only wires Google/GitHub |
| 10 | Bootstrap role cast "superadmin" as "admin" | ⬜ Phase 7 | Harmless today, fragile on BetterAuth upgrades |
| 11 | Passkeys as a true second factor | 🚫 Won't fix | BetterAuth limitation; TOTP/YubiKey-OATH covers 2FA |
| 12 | Rate limiting on auth endpoints | ✅ Resolved | Global 600 req/min per IP (@fastify/rate-limit) plus two strict auth/email buckets in server.ts (RATE_001/MAIL_003), in-memory or Redis-backed |
| 13 | Structured logging | ✅ Resolved | pino via Fastify + shared src/logger.ts |
| 14 | Security audit (PKCE, rotation, CORS) | ✅ Resolved | Phases 1–3 (PKCE enforced, JWKS rotation, CORS scoped, helmet) |
| 15 | Health check DB connectivity probe | ⬜ Phase 7 | /health is static today |
| 16 | Monitoring / alerting | 🚫 Won't fix | Operational concern, out of repo scope |
| 17 | Coverage ≥80 % on routes/** + services/** | ⬜ Phase 6 | Measurable now, threshold not yet met |
| 18 | BetterAuth plugin-hook integration tests | 🟡 Partial | Phase 6 — security-hardening + M2M tests cover key hooks |
| 19 | any usages in auth/consumption | ✅ Resolved | Phase 1 removed both |
| 20 | docker-compose.dev.yml redundant/broken | ✅ Resolved | Merged into a single docker-compose.yml (prebuilt image by default) |
Deliberately accepted (documented, not defects)
- M-02 cross-subdomain session cookie — kept opt-in via
SESSION_DOMAIN; sharing the IdP cookie across subdomains is a deliberate trade-off. - L-08 login IP/UA on OAuth issuance — BetterAuth's token-issuance callback exposes no request headers; the parent session row carries them.
- L-10 pin GitHub Actions by SHA — deferred to Dependabot (Phase 7) instead of hand-pinned SHAs.
Phase details
Phase 0 — Guardrails (✅)
- ESLint flat config —
no-explicit-any/consistent-type-importserrors,no-consolewarn. - Prettier +
pnpm format/pnpm format:check; husky + lint-staged pre-commit. @vitest/coverage-v8+pnpm test:coverage(thresholds declared; the ≥80 % gate lands in Phase 6).- CI workflow (build, lint, format:check, unit, integration on PostgreSQL 17) on PR + main.
- Removed tracked build artefacts from
frontend/.
Phase 1 — Urgent fixes (✅)
| ID | Item |
|---|---|
| H-04 | Strict env booleans (z.stringbool) |
| H-05 | /api/admin/services no longer returns provider secrets |
| C-01 | clientPrivileges admin-only; guard fail-closed on unknown client |
| H-01 | Hierarchy hook on native /admin/* endpoints |
| H-02 | isMfaRequired, lastLoginAt not user-writable |
| H-07 | Banned users refused; OAuth tokens revoked on ban / access revocation / secret rotation |
| M-06 | applications.isActive enforced; oauth_client.disabled synced |
| M-10 | /api/consumption Bearer verified via JWKS/DB, bound to azp, fail-closed |
Plus: magic-link storeToken: "hashed"; internal infrastructure references removed.
Phase 2 — BetterAuth upgrade (✅, 1.6.23 → 1.7.7)
latest (1.7.7) installed for better-auth, @better-auth/oauth-provider,
@better-auth/passkey, @better-auth/drizzle-adapter — no fallback needed.
- Schema:
src/db/auth-schema.tshand-aligned with the 1.7 schema (jwks.alg/crv, newoauth_clientcolumns, new tablesoauth_resource,oauth_client_resource,oauth_client_assertion); consolidated0000_initial_schema.sqlregenerated per the pre-production exception;migrate.tsreduced to a plainmigrate()call (with a clear "recreate the database" error when the schema already exists). - Code:
validAudiencesreplaced by RFC 8707 protected resources (oauth_resourcerows keyed byapplications.url, linked per client viaoauth_client_resource); 1.7 client metadata written on application create; M2M verification rewritten onverifyJwsAccessTokenwith a local JWKS read from thejwkstable,azp-bound and fail-closed; JWKS rotation enabled (30-day rotation, 30-day grace). - Tests: end-to-end
client_credentials→/api/consumption(success + cross-application rejection). Deferred: ID-token claims via a full authorization-code flow, end-session E2E (Phase 5).
docker compose down -v && docker compose pull && docker compose up -d), then re-register downstream clients. Set TRUST_PROXY_HOPS=1 when running behind a reverse proxy.Post-review fixes: 2FA guard covers /sign-in/social; Stripe handlers filter by (user_id, application_id, plan_id); stripe_events purged after 30 days; requireAdmin emits AUTH_011; last-superadmin demotion is a single conditional UPDATE; bootstrap rejects the shipped .env.example password pattern.
Phase 3 — Design-level hardening (✅)
| ID | Item |
|---|---|
| H-03 | Passwordless/social sign-in blocked for 2FA-enabled accounts (top-level after hook, fail-closed) |
| H-06 | select-org.html XSS fixed (textContent, escaped hidden input) |
| M-01 | Credentialed CORS scoped to dashboard origins; app origins only on /api/auth/* |
| M-03 | disableSettingJwtHeader (no more set-auth-jwt) |
| M-04 | Request logs strip query strings (tokens) |
| M-05 | allowRegister defaults to false (DB + API + pages) |
| M-07 | TRUST_PROXY_HOPS defaults to 0 |
| M-08 | @fastify/helmet (HSTS, nosniff, frame-ancestors, CSP) |
| M-09 | Stripe webhook idempotency (stripe_events claim + release on error) |
| L-01 | Slug validated with ^[a-z0-9-]+$ before template path resolution |
| L-03 | Last superadmin cannot be demoted via PATCH (atomic) |
| L-04 | Weak/default bootstrap password refused in production |
| L-05 | Dockerfile runs as USER node |
Phase 4 — Refactoring (✅ core)
- Shared
src/middleware.ts(requireAdmin,requireSession,requireFullSession,getCallerRole) — 9 duplicated copies removed. - Shared
src/logger.ts(pino);console.*removed from server code. AUTH_011(403) added;ZodError→ 400 and BetterAuthAPIErrormapping in the global error handler.- Fail-fast bootstrap;
user_subscriptionsupsert on(user_id, application_id).
Phase 5 — New features & email delivery hardening (🟡 5a done · 5b partial)
5a — Email delivery hardening (from the delivery audit) — ✅ done
The audit found that an account created by an admin receives no verification
email, because BetterAuth's native /admin/create-user endpoint never calls
sendVerificationEmail (only /sign-up/email consults sendOnSignUp). A
configured SMTP therefore still produced no mail. Shipped:
POST /api/admin/userssends the verification email after creation (best-effort and detached; a transport failure does not roll back the account, and it is skipped when the no-op transport is active).- No duplicate activation mail:
sendOnSignInis intentionally off (it re-sent on every failed sign-in attempt). The login page now detectsEMAIL_NOT_VERIFIEDand offers an explicit, rate-limited "resend" action instead. - Verification links land on
/profile(BetterAuth's defaultcallbackURL=/dropped regular users on the admin-only dashboard → 403). The SPA defaults/to/profileand shows a Dashboard entry in the user menu only for admins. - Admin UI:
POST /api/admin/users/:id/send-verificationreverts the account to unverified and re-sends (buttons in the user list action menu and the user detail page);POST /api/admin/users/:id/verify-emailmarks it verified without the link (superadmin-only for admin/superadmin targets). verify-email.html"Resend" now collects the address and POSTs it (it previously sent an empty body).- Fail loud instead of silent: the no-op transport is no longer reported as
sent;sendEmail()logs aDROPPEDwarning instead. - Rate-limit the added mail senders (
/change-email,/organization/invite-member) in the email bucket./sign-up/emailstays in the auth bucket only: its verification mail is a side effect of account creation, and the extra 5/min cap breaks legitimate provisioning. docker-compose.yml: mail-catcher (mailpit) behind themailprofile andMAIL_REPLY_TOpassthrough.- Transactional email design pass across the built-in
.emltemplates (seven intemplates/default/emails/today): a shared branded shell (table layout, inline styles), the instance logo embedded as a CID attachment (APP_LOGO_URLresolved to bytes, no external image request), and tightened copy. Bundled templates are wrapped; tenant/external overrides are left verbatim. Logo resolution is SSRF-guarded (private/link-local hosts and redirects are rejected), size-capped (256 KB), deduplicated, and warmed in the background so it never blocks a send.
Covered by src/tests/email-admin-created.integration.test.ts and
src/tests/email-verify.integration.test.ts.
5b — New features (partial)
Shipped:
- Shared rate-limit store (
src/services/rate-limit-store.ts): in-memory by default, Redis-backed whenREDIS_URLis set (lazyioredis, atomic INCR/PEXPIRE; on a Redis error it falls back to per-instance limits rather than failing open or blocking all auth). Composeredisservice behind aredisprofile. - Opt-in BetterAuth plugins, each gated by env:
haveIBeenPwned(HAVEIBEENPWNED_ENABLED, default off),captcha(explicitCAPTCHA_ENABLED, default off, plusCAPTCHA_PROVIDER/CAPTCHA_SECRET_KEY/CAPTCHA_SITE_KEY/CAPTCHA_ENDPOINTS),lastLoginMethod(LAST_LOGIN_METHOD_ENABLED, default on: it is a cookie hint with no schema impact). - Device authorization grant (
DEVICE_AUTHORIZATION_ENABLED): plugin, thedevice_codetable inlined into the consolidated0000_initial_schema.sqlbaseline (pre-production: no delta migration), and the/deviceapproval page (Vue SPA view + server-rendered template).
Remaining in 5b:
- Captcha client side: the plugin rejects
/sign-in/email,/sign-up/emailand/request-password-resetunless the request carries anx-captcha-responseheader. Neither the Vue SPA nor the server templates render a widget yet, and the site key is not exposed to clients, so leavingCAPTCHA_ENABLED=falseis mandatory until the widget ships. - Back-channel logout (
backchannelLogoutUri,backchannelLogoutSessionRequired) is exposed in the admin application create/update schema, the admin UI and the integration guide; delivery, token revocation and single-use semantics are covered bysrc/tests/backchannel-logout.integration.test.ts. Still to expose: DPoP (dpopBoundAccessTokens) — the schema column exists insrc/db/auth-schema.ts, but neither the admin application schema (src/routes/admin/applications.ts), the admin UI nor any test references it. @better-auth/i18n: BetterAuth 1.7.7 ships no i18n package, so this needs a dedicated design pass (message catalog + locale negotiation) rather than a config toggle.- Evaluate CIMD/DCR and
auth.api.adminCreateOAuthClientvs. the hand-written client insertion.
Phase 6 — Quality & coverage (⬜)
src/types.tscentralisation; splitapplications.ts(<1000 lines); renameadminConsumption.ts→ kebab-case.- Explicit
.select({…})/.returning({…})sweep; transaction audit. - Raise
routes/**+services/**coverage to ≥80 % and make it gate CI (thethresholds: { lines: 80 }is declared invitest.config.ts); add unit tests foruser.ts,stripe-webhook.ts(webhook dispatch is covered bysrc/tests/billing-webhook.integration.test.ts),bootstrap.ts,migrate.ts. - Email coverage: no-op transport, every OTP type, contact-email and org-invitation senders, and a test that runs the email flags at their production defaults (today
tests/setup-env.tsforces them on, hiding gaps). Admin-created-user send + resend + mark-verified are already covered byemail-admin-created.integration.test.ts. - Error-code sync between the specifications and
src/errors.ts.
Phase 7 — Loose ends (⬜)
- Wire LinkedIn / Microsoft / Apple in
auth.ts(or drop them fromconfig.tsto avoid a silent no-op). - Align the template
expiresIn*values with BetterAuth's real token lifetimes (the OTP wording is now type-aware). - Replace the
createUserrole cast inbootstrap.tswith a typed path. - Add a DB connectivity probe to
GET /health. - Add Dependabot (npm + GitHub Actions), which also resolves L-10.
Status at a glance
| Area | State |
|---|---|
| Email/password, verification, password reset, change-email | ✅ |
| Magic-link / email-OTP (opt-in), passwordless 2FA guard | ✅ |
| Email delivery hardening (admin send, resend, SMTP guard, mail catcher) | ✅ Phase 5a |
| Transactional email branding (embedded CID logo, shared layout) | ✅ Phase 5a |
| Login/verification UX (no duplicate mail, /profile callback, admin resend UI) | ✅ Phase 5a |
| Optional plugins (haveIBeenPwned, captcha, lastLoginMethod) | ✅ Phase 5b (opt-in) |
| Bulk admin actions (CSV user import, bulk app access) | ✅ |
| Device authorization grant (RFC 8628, opt-in) | ✅ Phase 5b |
| Back-channel logout | ✅ Phase 5b (native; admin schema, admin UI and tests in place) |
DPoP (dpopBoundAccessTokens) admin surface | ⬜ Phase 5b (schema column only; no admin schema, UI or test) |
| TOTP 2FA + backup codes, admin force-MFA | ✅ |
| Passkeys | 🟡 Server plugin registered; no UI (profile shows "Coming soon"); YubiKey supported via OATH-TOTP |
| OAuth 2.1 / OIDC (PKCE, consent, refresh, discovery, JWKS, rotation) | ✅ |
| Protected resources (RFC 8707) + per-client linking | ✅ |
Machine-to-machine (client_credentials) + consumption API | ✅ |
| Orgs, per-app roles/permissions, subscriptions + Stripe webhook | ✅ |
| Admin dashboard (users, apps, plans, roles, sessions, consumption) | ✅ |
| Guardrails (ESLint/Prettier/husky, CI, integration tests PG17) | ✅ |
| Coverage ≥80 % gate | ⬜ Phase 6 |
| Rate-limit shared storage (multi-instance) | ✅ Phase 5b (Redis opt-in) |
Recently shipped
- Stripe subscriptions and billing: per-product paid plans with fixed and metered prices, Checkout / Billing Portal, webhook-driven entitlements, invoice cache, application billing API, delegated billing pages and
pnpm billing:reconcile. Lots 0–7 are done and Lot 8 is partial (repository-wide coverage gate not met); see the Stripe billing programme plan. - Admin real-time channel — widened event vocabulary (WP3 of #60): the channel now carries five more event types beside
login.recorded—session.created,session.revoked,user.changed,application.changedandorganization.changed. Session events come from the BetterAuthdatabaseHooks.session.create.after/delete.afterhooks, so every session creation and removal passes through them whatever the caller (native/api/auth/*endpoints included);user.changedcomes from both the core user hooks and the direct Drizzle writes the adapter does not see;application.changedfrom the writes insrc/routes/admin/applications.tsandapplication-machine-scopes.tsand the startup bootstrap;organization.changedfrom the organization plugin's hooks plus the direct organization/member writes. Each type's emitting call sites are listed in the specifications (§7.8), and the frame is unchanged: an event name and a timestamp, never a payload. Covered bysrc/services/event-bus.test.tsandsrc/tests/events.integration.test.ts. - Admin real-time channel — dashboard consumption (WP2 of #60): the admin dashboard now subscribes to
GET /api/admin/eventsthroughfrontend/src/composables/useAdminEvents.tsand refreshes its logins series and online counter onlogin.recorded, coalescing a burst of events into one round of requests inside a 1 s window. Only the dashboard subscribed at the time of WP2; the composable is inert underVITE_USE_MOCK, closes the stream on unmount and leaves reconnection toEventSource. The 30 sactive-userspoll is unchanged and remains the safety net. The composable has since been extended:wallet.changedandsubscription.changedare also handled, and the application detail, wallets and accounting views subscribe too. No frontend test runner is introduced — the frontend evidence is observational. - Admin real-time channel (SSE, WP1 of #60):
GET /api/admin/eventsstreams Server-Sent Events to the admin dashboard, guarded by the samerequireAdminpreHandler as the other/api/admin/*routes (401 / 403 / 200). A frame names an event type (login.recordedin WP1; the vocabulary has since grown to eight types, see below) and carries at most a timestamp; no payload, no identifier, no count ever travels on the stream, and the client re-fetches through the REST endpoints it already calls. The event bus is in-process by default and fans out over Redis pub/sub whenREDIS_URLis set, mirroring the rate-limit store. Keep-alive comment frames hold an idle stream open and every subscriber is unsubscribed on connection close, soEventSourcereconnections cannot accumulate. Delivery is best effort: the dashboard's 30 sactive-userspoll remains the safety net. Covered bysrc/routes/admin/events.test.ts,src/services/event-bus.test.tsandsrc/tests/events.integration.test.ts. - Admin password reset:
POST /api/admin/users/:id/set-passwordsets a new password for a strictly lower-ranked user and, in the same operation, revokes the target's sessions and OAuth refresh tokens — the credential write and the revocation are one gesture, so a reset on a compromised account actually takes the session away. The native/api/auth/admin/set-user-passwordendpoint is withheld from every role (set-passwordremoved from the admin and superadmin permission lists) because it writes the credential and revokes nothing; the custom route is the only path. A distinctcanManageRolegate (strict rank) drives both the server check and the admin UI action, so the UI never offers a reset the server would refuse. Admin UI: "Set Password" action on the user detail page reusing the WP2 password-strength meter. - Phase 5a/5b: admin-created users now receive a verification email (plus admin resend / mark-verified), no-op transport no longer logs a false
sent, mail senders rate-limited,mailpitcatcher; opt-inhaveIBeenPwned/captcha/lastLoginMethodplugins, RFC 8628 device authorization with/devicepage, and a shared rate-limit store (in-memory or Redis viaREDIS_URL). - Email follow-up: removed the sign-in re-send that duplicated activation mails, verification links now land on
/profile(with an admin-only Dashboard menu entry), admin UI exposes resend / mark-verified, and the built-in transactional templates share a branded layout with the instance logo embedded as a CID attachment. - Admin UX: verification actions are gated by SMTP/verification readiness and caller rights; Ban/Delete are hidden for self and higher-ranked targets; the OAuth/Integration code block matches the details block height (scrollable + copy button); bulk CSV user import and bulk application access by organization; tenant templates (
mcp-central,vandal-app) converted to Eta and completed with select-org/device pages and transactional emails. - Compose / packaging: single functional
docker-compose.yml; docs prioritise the publishedlatest/nightlyimages; production install fixed (husky || trueunder--prod). - Phase 3: 2FA enforced for passwordless/social sign-in,
select-orgXSS fixed, credentialed CORS scoped to the dashboard, request logs token-free,allowRegister/TRUST_PROXY_HOPSsecure defaults, helmet headers, Stripe webhook idempotency, last-superadmin protection, non-root container. - Phase 4: shared
middleware.tsandlogger.ts,AUTH_011403 + Zod/BetterAuth error mapping, fail-fast bootstrap,user_subscriptionsupsert fix. - Phase 2: BetterAuth 1.7.7, regenerated baseline, protected resources, client metadata, native
revokeSession, local-JWKS M2M verification with E2E test, JWKS rotation. - Phase 1: OAuth client management admin-only, fail-closed access guard, admin hierarchy hook, strict boolean env parsing, provider secrets no longer exposed, OAuth token revocation on ban/revocation/rotation.
- Phase 0: ESLint + Prettier + husky/lint-staged, coverage tooling, CI on PR, integration tests on PostgreSQL 17, tracked build artefacts removed.
Infrastructure
- Single
docker-compose.yml—postgres+auth-service(prebuilt image or--build), healthcheck on/health, postgres exposed on 5433 for host-based dev. - Images — published to
ghcr.io/circle-rd/auth-serviceas:nightly(develop),:latest(release) and version tags. - Dockerfile — three-stage (frontend → backend → runtime), runs as
USER node. - Migrations —
runMigrations()at startup; until the first production release the consolidated0000baseline is regenerated from the schema and no delta migration is shipped (see AGENTS.md §8), so a reinitialised or older database must be recreated (docker compose down -v).