From e3ea89c34e0abe9ac30d710f9dfd563b7e5f1f32 Mon Sep 17 00:00:00 2001 From: techAlhaji Date: Thu, 15 Oct 2026 18:02:37 +0000 Subject: [PATCH 1/4] feat(admin): secure bootstrap CLI, admin invites, and account deactivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin accounts were "seeded or invited" on paper but neither path existed β€” the preview admin was recreated by hand with SQL against the Render database, and there was no way to shut down a compromised or departed account without deleting rows. Adds a create-admin CLI that reads credentials from stdin (never argv, so they stay out of ps/history/CI logs) and refuses to run when an admin already exists unless --force is passed. Invites are hashed, expiring and single-use, redeemed into a session at /auth/signup-with-invite. Deactivation soft-flags the account and invalidates its sessions: login, refresh and already-issued access tokens all stop working, the last active admin is protected, and no clinical or payment record is ever removed. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- ARCHITECTURE.md | 18 +- ARCHITECTURE_ESSENTIALS.md | 2 +- ROADMAP.md | 18 +- backend/.env.example | 5 + .../migration.sql | 26 ++ backend/prisma/schema.prisma | 34 ++ backend/scripts/create-admin.mjs | 170 +++++++++ backend/src/app.ts | 2 + backend/src/config/index.ts | 6 + backend/src/middleware/auth.ts | 24 +- backend/src/modules/auth/controllers/index.ts | 14 +- backend/src/modules/auth/routes/index.ts | 3 + backend/src/modules/auth/services/index.ts | 74 ++++ backend/src/modules/auth/services/tokens.ts | 4 + backend/src/modules/auth/validation.ts | 8 + .../src/modules/users/controllers/index.ts | 47 ++- .../src/modules/users/repositories/index.ts | 104 +++++- backend/src/modules/users/routes/index.ts | 23 +- backend/src/modules/users/services/index.ts | 170 ++++++++- .../modules/users/services/invite-tokens.ts | 14 + backend/src/modules/users/validation.ts | 7 + .../integration/admin-lifecycle.test.mts | 347 ++++++++++++++++++ .../tests/unit/admin-invite-tokens.test.ts | 27 ++ docs/adr/0003-render-preview-deployment.md | 22 +- docs/api/README.md | 26 +- scripts/README.md | 69 +++- 26 files changed, 1237 insertions(+), 27 deletions(-) create mode 100644 backend/prisma/migrations/20261015175817_add_admin_lifecycle/migration.sql create mode 100644 backend/scripts/create-admin.mjs create mode 100644 backend/src/modules/users/services/invite-tokens.ts create mode 100644 backend/src/modules/users/validation.ts create mode 100644 backend/tests/integration/admin-lifecycle.test.mts create mode 100644 backend/tests/unit/admin-invite-tokens.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c98333d..4d1a1d2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -41,7 +41,7 @@ See `PRD.md` for product scope and `ARCHITECTURE_ESSENTIALS.md` for the quick-re Each module under `backend/src/modules//` owns its own `routes/`, `controllers/`, `services/`, `repositories/`. Cross-module access happens through a module's exported service interface only β€” **never reach into another module's repository directly.** This is the internal seam that would let us extract a module into its own service later without a rewrite. - `auth` β€” signup/login, tokens, role checks. -- `users` β€” shared user profile fields common to all roles. +- `users` β€” shared user profile fields common to all roles, plus the admin account lifecycle (invites, soft deactivation β€” issue #39). `auth` calls this module's services to create an invited admin; `users` never calls back into `auth`. - `patients` β€” patient-specific profile data. - `doctors` β€” doctor profile, specialties, verification status. - `facilities` β€” lab/pharmacy accounts, capability tags (what order types they can fulfill). @@ -64,8 +64,24 @@ Canonical source of truth: `backend/prisma/schema.prisma`. This section is the h ``` User id, email, phone, password_hash, role (enum: patient|doctor|facility|admin) + deactivated_at (nullable β€” Phase 8 issue #39. Soft deactivation: login, + refresh and access tokens are all refused while set. Nothing is ever + deleted by it β€” no clinical or payment record is keyed to this flag) + sessions_invalidated_at (nullable β€” set on deactivate *and* reactivate, so + access tokens minted earlier stop working immediately instead of living + out their 15-minute TTL; tokens with no `iat` fail closed. Not a general + revocation list β€” that's still issue #18) created_at, updated_at +AdminInvite (Phase 8 issue #39) + id, email, token_hash (unique, SHA-256 β€” raw token never stored) + expires_at, consumed_at (nullable), invited_by_user_id (FK -> User), created_at + # Single-use and expiring; redeeming it is what `POST /auth/signup-with-invite` + does, in one transaction with creating the admin account. Issuing a new + invite for an address retires any outstanding one. Admin-only: PRD OQ-2's + facility invites need their own profile fields at redemption, so they are + deliberately not folded in. + PatientProfile (1:1 User) id, user_id, full_name, date_of_birth, guardian_user_id (nullable β€” see PRD edge case: minors) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 257f27b..244b372 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -19,7 +19,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc 6. Every module = `routes/ controllers/ services/ repositories/`. Keep that shape. ## Data model map (see ARCHITECTURE.md Β§4 for full field lists) -`User` β†’ `PatientProfile | DoctorProfile | FacilityProfile` +`User` β†’ `PatientProfile | DoctorProfile | FacilityProfile`; `User` β†’ `AdminInvite` (Phase 8, issue #39 β€” hashed/expiring/single-use; `User.deactivatedAt` soft-deactivates an account, refusing login/refresh/access tokens without deleting anything) `DoctorProfile` β€”< `DoctorSpecialty` >β€” `Specialty` `AvailabilitySlot` (doctor's calendar) β†’ `Appointment` β†’ `IntakeForm`, `Consultation` `Consultation` β†’ (outcome=referred) β†’ `Referral` (snapshot!) β†’ new `Appointment` diff --git a/ROADMAP.md b/ROADMAP.md index bf51c73..06280b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,7 +107,9 @@ for status and issue links. - [ ] ~~Shared: auth screens (signup/login per role).~~ β†’ already built, `docforum-web` Phase W1. ## Phase 8 β€” Hardening (safety, audit, security) -**Status: Not started. Three of five items scoped into issues #18–#20 (independent of Phases 3–7); the other two remain blocked.** +**Status: In progress. In addition to the original five items (#18–#20 scoped, two still blocked), four auth-hardening items are scoped as #34, #35, #37 and #39 and are being implemented now β€” each independent of the others and of Phases 3–7.** + +- [ ] Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation. `scripts/create-admin.mjs` (credentials via stdin, never argv; refuses when an admin already exists unless `--force`) β€” now the supported path in ADR 0003's renewal runbook; `AdminInvite` (hashed, expiring, single-use) issued by `POST /admin/invites` and redeemed at `POST /auth/signup-with-invite`; `User.deactivatedAt` + `sessionsInvalidatedAt` with `PATCH /admin/users/:id/{deactivate,reactivate}`, refusing login, refresh *and* already-issued access tokens, guarded so the last active admin can't be deactivated, and deleting nothing. Tracked as [issue #39](https://github.com/DocForum/docforum-core/issues/39) (High, 200 pts) β€” **in progress**, branch `feat/admin-account-lifecycle-secure-bootstrap-cli`. - [ ] `AuditEvent` table + write path for referral/prescription/lab-order state transitions (ARCHITECTURE.md Β§6.2.4 β€” flagged as needed before real patient data). Still blocked β€” needs the Phase 3/4 models (referral, prescription, lab order) to exist first. Not yet scoped into an issue. - [ ] JWT refresh revocation list (ARCHITECTURE.md Β§6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) β€” unblocked now, operates on Phase 1's auth module. - [ ] Held-slot TTL/release job (ARCHITECTURE.md Β§6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) β€” unblocked now, operates on Phase 1's `AvailabilitySlot`. @@ -341,3 +343,17 @@ for status and issue links. yet Accepted. Added to the docs site. - 2026-10-09 β€” Opened issue #32: upgrade to escrow SDK 0.3.0 and the current testnet contract. +- 2026-10-15 β€” Started Phase 8's auth-hardening work, scoped as issues + #34 (email verification), #35 (login brute-force throttling), #37 (CSRF + for the cookie-authenticated endpoints + per-environment CORS allowlist) + and #39 (admin account lifecycle). All four are independent of Phases + 3–7 and of each other. #39 has landed as a PR: a supported + `scripts/create-admin.mjs` bootstrap CLI (credentials on stdin, never + argv; `--force` required once an admin exists) which now replaces the + hand-written SQL promotion in ADR 0003's renewal runbook, an + `AdminInvite` table (hashed/expiring/single-use) behind + `POST /admin/invites` + `POST /auth/signup-with-invite`, and soft + deactivation (`User.deactivatedAt` + `sessionsInvalidatedAt`) with + `PATCH /admin/users/:id/{deactivate,reactivate}` that refuses login, + refresh and already-issued access tokens, can't touch the last active + admin, and deletes no records. diff --git a/backend/.env.example b/backend/.env.example index 03c9b5d..531a290 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,6 +9,11 @@ CORS_ORIGIN=http://localhost:5173 # a filled-in .env. Generate + fund your own with: # node scripts/gen-platform-identities.mjs # (writes secrets directly to .env, never prints them). +# Admin invites (Phase 8, issue #39). `ADMIN_INVITE_ACCEPT_URL` is where the +# invite link points β€” docforum-web owns that page. +ADMIN_INVITE_TTL_HOURS=72 +ADMIN_INVITE_ACCEPT_URL=http://localhost:5173/accept-admin-invite + PLATFORM_PAYER_SECRET= PLATFORM_RELEASER_SECRET= # The live testnet docforum-escrow deployment this repo currently targets diff --git a/backend/prisma/migrations/20261015175817_add_admin_lifecycle/migration.sql b/backend/prisma/migrations/20261015175817_add_admin_lifecycle/migration.sql new file mode 100644 index 0000000..58c59ce --- /dev/null +++ b/backend/prisma/migrations/20261015175817_add_admin_lifecycle/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "deactivatedAt" TIMESTAMP(3), +ADD COLUMN "sessionsInvalidatedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "AdminInvite" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "invitedByUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminInvite_tokenHash_key" ON "AdminInvite"("tokenHash"); + +-- CreateIndex +CREATE INDEX "AdminInvite_email_idx" ON "AdminInvite"("email"); + +-- AddForeignKey +ALTER TABLE "AdminInvite" ADD CONSTRAINT "AdminInvite_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e0239b7..608a639 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -100,6 +100,17 @@ model User { phone String? passwordHash String role Role + // Phase 8 (issue #39): null = active. Deactivating never deletes anything + // β€” no clinical or payment record is keyed to this flag, and none of the + // deactivation paths remove rows. Login, refresh and access tokens are all + // refused while this is set. + deactivatedAt DateTime? + // Phase 8 (issue #39): bumped when an account is deactivated/reactivated, + // so access tokens minted before that moment stop working immediately + // instead of living out their 15-minute TTL. There is still no general + // refresh-token revocation list (issue #18) β€” this is the narrow "revoke + // everything for one account" case that deactivation needs. + sessionsInvalidatedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -107,9 +118,32 @@ model User { doctorProfile DoctorProfile? facilityProfile FacilityProfile? + adminInvitesSent AdminInvite[] + @@index([role]) } +// Phase 8 (issue #39): admin invites. Admin accounts are "seeded or +// invited" (ROADMAP Phase 8) and there was no supported path for either. +// Only the SHA-256 hash of the token is stored β€” a database leak must not +// hand out working invite links β€” and every invite is single-use and +// expiring. Admin-only scope for now: PRD OQ-2's facility invites will need +// their own profile fields (name/type) at redemption, so they are +// deliberately not folded in here. +model AdminInvite { + id String @id @default(uuid()) + email String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + invitedByUserId String + createdAt DateTime @default(now()) + + invitedBy User @relation(fields: [invitedByUserId], references: [id], onDelete: Cascade) + + @@index([email]) +} + model PatientProfile { id String @id @default(uuid()) userId String @unique diff --git a/backend/scripts/create-admin.mjs b/backend/scripts/create-admin.mjs new file mode 100644 index 0000000..7caf1ed --- /dev/null +++ b/backend/scripts/create-admin.mjs @@ -0,0 +1,170 @@ +// Admin bootstrap CLI (Phase 8, issue #39). +// +// Admin accounts are "seeded or invited" per the roadmap, but no supported +// path existed: the preview admin was recreated by hand with an +// `UPDATE "User" SET role = 'admin'` against the Render database (ADR 0003's +// renewal runbook). This is that supported path. +// +// Two rules this script exists to honour: +// 1. Credentials are never read from argv. `ps`, shell history and CI logs +// all record command lines, so the password is read from stdin instead β€” +// silently, when there's a TTY to do it with. A password passed in argv +// is refused rather than quietly ignored. (The email *is* accepted as a +// flag: an address is not a secret.) +// 2. Secrets are never printed or logged β€” no password, no hash, no echo. +// +// Usage: +// node scripts/create-admin.mjs --email admin@example.com +// printf 'admin@example.com\n\n' | node scripts/create-admin.mjs +// node scripts/create-admin.mjs --email admin@example.com --force +// +// `--force` is required when an active admin already exists, so a stray run +// can't quietly add a second administrator. +import 'dotenv/config'; +import bcrypt from 'bcryptjs'; +import { PrismaClient } from '@prisma/client'; + +const SALT_ROUNDS = 12; +const MIN_PASSWORD_LENGTH = 8; + +const prisma = new PrismaClient(); + +function fail(message) { + console.error(`create-admin: ${message}`); + process.exit(1); +} + +/** Refuses every argv form of a password rather than "just this once". */ +function assertNoPasswordInArgv(argv) { + const offending = argv.find((arg) => /^--password(=|$)/.test(arg) || /^--password-hash(=|$)/.test(arg)); + if (offending) { + fail( + 'passwords must not be passed as command-line arguments β€” they leak via `ps`, shell history and CI logs. ' + + 'Run interactively, or pipe the password on stdin.', + ); + } +} + +function parseArgs(argv) { + const emailFlag = argv.find((arg) => arg.startsWith('--email=')); + const emailIndex = argv.indexOf('--email'); + return { + email: emailFlag ? emailFlag.slice('--email='.length) : emailIndex >= 0 ? argv[emailIndex + 1] : undefined, + force: argv.includes('--force'), + }; +} + +/** Prompts for one line, without echoing it when `silent`. Only used when + * stdin is a TTY β€” see `readPipedLines` for the other path. */ +function promptLine(prompt, { silent = false } = {}) { + return new Promise((resolve, reject) => { + process.stdout.write(prompt); + process.stdin.setEncoding('utf8'); + process.stdin.resume(); + process.stdin.setRawMode?.(silent); + + let value = ''; + const cleanup = () => { + process.stdin.setRawMode?.(false); + process.stdin.pause(); + process.stdin.off('data', onData); + process.stdin.off('error', onError); + }; + const onError = (err) => { + cleanup(); + reject(err); + }; + const onData = (chunk) => { + for (const char of chunk.toString('utf8')) { + if (char === '\u0003') { + // Ctrl-C β€” never leave the terminal in raw mode. + cleanup(); + process.stdout.write('\n'); + process.exit(130); + } + if (char === '\n' || char === '\r') { + cleanup(); + if (silent) process.stdout.write('\n'); + resolve(value); + return; + } + if (char === '\u007f' || char === '\b') { + value = value.slice(0, -1); + continue; + } + value += char; + } + }; + + process.stdin.on('data', onData); + process.stdin.on('error', onError); + }); +} + +/** Piped input is read to completion up front β€” reading line-by-line off a + * stream loses the rest of a chunk, which silently hangs the second prompt. */ +async function readPipedLines() { + let text = ''; + for await (const chunk of process.stdin) text += chunk; + const lines = text.split('\n').map((line) => line.replace(/\r$/, '')); + // A trailing newline leaves one empty element; that isn't a line. + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + return lines; +} + +async function main() { + const argv = process.argv.slice(2); + assertNoPasswordInArgv(argv); + const { email: emailFlag, force } = parseArgs(argv); + + const activeAdmins = await prisma.user.count({ where: { role: 'admin', deactivatedAt: null } }); + if (activeAdmins > 0 && !force) { + fail( + `${activeAdmins} active admin account(s) already exist. Re-run with --force to add another, or use ` + + 'POST /admin/invites to invite one.', + ); + } + + const interactive = Boolean(process.stdin.isTTY); + const piped = interactive ? [] : await readPipedLines(); + + const email = (emailFlag ?? (interactive ? await promptLine('Admin email: ') : piped.shift() ?? '')).trim(); + if (!email) fail('an email address is required.'); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) fail(`"${email}" does not look like an email address.`); + + if (await prisma.user.findUnique({ where: { email } })) { + fail(`an account already exists for ${email}. Promote or reactivate it instead of re-creating it.`); + } + + let password; + if (interactive) { + password = await promptLine('Admin password (min 8 chars, not echoed, never in argv): ', { silent: true }); + const confirmation = await promptLine('Confirm password: ', { silent: true }); + if (password !== confirmation) fail('the two passwords did not match.'); + } else { + // Piped input is `email` then `password` β€” no confirmation line, so a + // password can't be typed twice by accident in a script. + password = piped.shift() ?? ''; + } + if (password.length < MIN_PASSWORD_LENGTH) fail(`the password must be at least ${MIN_PASSWORD_LENGTH} characters.`); + + // Hashed before anything persistent sees it, and never logged. + const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); + + const user = await prisma.user.create({ + data: { + email, + passwordHash, + role: 'admin', + }, + }); + + console.log(`Created admin ${user.email} (${user.id}).`); + console.log('The password was not printed, logged or stored anywhere else β€” put it in your password manager now.'); +} + +main() + .catch((err) => { + fail(err instanceof Error ? err.message : String(err)); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/src/app.ts b/backend/src/app.ts index eec58ee..c8f285f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,6 +8,7 @@ import { doctorsRouter } from './modules/doctors/routes'; import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; import { paymentsRouter } from './modules/payments/routes'; +import { adminRouter } from './modules/users/routes'; export function createApp() { const app = express(); @@ -23,6 +24,7 @@ export function createApp() { app.use('/availability', availabilityRouter); app.use('/appointments', appointmentsRouter); app.use('/payments', paymentsRouter); + app.use('/admin', adminRouter); // Must be registered last β€” Express only routes errors here once every // other handler has had a chance to run. diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 3339a26..42b6c9f 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -21,6 +21,12 @@ export const config = { refreshTtl: '30d', }, corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173', + // Admin invites (Phase 8, issue #39). `acceptUrl` is where the invite link + // points β€” docforum-web owns the page that consumes `?token=`. + adminInvites: { + ttlHours: Number(process.env.ADMIN_INVITE_TTL_HOURS ?? 72), + acceptUrl: process.env.ADMIN_INVITE_ACCEPT_URL ?? 'http://localhost:5173/accept-admin-invite', + }, // Deliberately NOT required at startup via requireEnv() β€” unrelated // parts of the app (auth, availability, etc.) shouldn't fail to boot // just because payments isn't configured. The payments module itself diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 114640c..1a808a9 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,5 +1,5 @@ import type { NextFunction, Request, Response } from 'express'; -import { verifyAccessToken } from '../modules/auth/services'; +import { assertActiveSession, verifyAccessToken } from '../modules/auth/services'; import { HttpError } from '../http-error'; import type { Role } from '@prisma/client'; @@ -13,22 +13,30 @@ declare global { } /** Verifies the `Authorization: Bearer ` header and attaches - * `req.user`. Throws 401 if missing/invalid/expired β€” there is no - * revocation list yet, so an expired token is the only way an access - * token stops working before its 15-minute TTL (see config/index.ts). */ -export function authenticate(req: Request, _res: Response, next: NextFunction) { + * `req.user`. + * + * Throws 401 if missing/invalid/expired. Beyond expiry, `assertActiveSession` + * is a database read per request that rejects a deactivated account and any + * token minted before that account's sessions were invalidated (Phase 8, + * issue #39) β€” without it, a deactivated user's access token would keep + * working for the rest of its 15-minute TTL. This is not a general + * revocation list; that is still issue #18. */ +export async function authenticate(req: Request, _res: Response, next: NextFunction) { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { throw new HttpError(401, 'Missing or malformed Authorization header.'); } + let payload; try { - const payload = verifyAccessToken(header.slice('Bearer '.length)); - req.user = { id: payload.sub, role: payload.role }; - next(); + payload = verifyAccessToken(header.slice('Bearer '.length)); } catch { throw new HttpError(401, 'Invalid or expired access token.'); } + + await assertActiveSession(payload.sub, payload.iat); + req.user = { id: payload.sub, role: payload.role }; + next(); } /** Must run after `authenticate`. Rejects with 403 if the authenticated diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts index ea1c07b..9eb370c 100644 --- a/backend/src/modules/auth/controllers/index.ts +++ b/backend/src/modules/auth/controllers/index.ts @@ -1,6 +1,6 @@ import type { Request, Response } from 'express'; import * as authService from '../services'; -import { loginSchema, signupSchema } from '../validation'; +import { loginSchema, signupSchema, signupWithInviteSchema } from '../validation'; import { HttpError } from '../../../http-error'; const REFRESH_COOKIE = 'docforum_refresh'; @@ -50,6 +50,18 @@ export async function login(req: Request, res: Response) { sessionResponse(res, session); } +/** + * Admin invite redemption (Phase 8, issue #39). Lives in the auth module + * because it ends in a session, but the invite itself is the users module's + * business β€” see `authService.signupWithInvite`, which calls that module's + * service rather than touching its tables. + */ +export async function signupWithInvite(req: Request, res: Response) { + const input = signupWithInviteSchema.parse(req.body); + const session = await authService.signupWithInvite(input); + sessionResponse(res, session); +} + export async function refresh(req: Request, res: Response) { const token = (req.cookies as Record | undefined)?.[REFRESH_COOKIE]; if (!token) throw new HttpError(401, 'No refresh token provided.'); diff --git a/backend/src/modules/auth/routes/index.ts b/backend/src/modules/auth/routes/index.ts index c048acf..2a0e68c 100644 --- a/backend/src/modules/auth/routes/index.ts +++ b/backend/src/modules/auth/routes/index.ts @@ -4,6 +4,9 @@ import * as authController from '../controllers'; export const authRouter = Router(); authRouter.post('/signup', authController.signup); +// Phase 8, issue #39: admin invite redemption (the admin invite itself is +// issued by `POST /admin/invites` in the users module). +authRouter.post('/signup-with-invite', authController.signupWithInvite); authRouter.post('/login', authController.login); authRouter.post('/refresh', authController.refresh); authRouter.post('/logout', authController.logout); diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index eaf3b5e..f7ced61 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -5,6 +5,7 @@ import { HttpError } from '../../../http-error'; import { createSelfServeUser, findUserByEmail, findUserById } from '../repositories'; import { hashPassword, verifyPassword } from './password'; import { signAccessToken, signRefreshToken, verifyRefreshToken } from './tokens'; +import { redeemAdminInvite } from '../../users/services'; import type { Role } from '@prisma/client'; // Re-exported so middleware/auth.ts (shared infra, not a peer module) can @@ -89,10 +90,47 @@ export async function login(input: LoginInput): Promise { const valid = await verifyPassword(input.password, user.passwordHash); if (!valid) throw new HttpError(401, 'Invalid email or password.'); + // Checked *after* the password: a deactivated account must not be + // distinguishable from a non-existent one without knowing the password + // (Phase 8, issue #39). + if (user.deactivatedAt) { + throw new HttpError(403, 'This account has been deactivated. Contact an administrator.'); + } + const tokens = issueSession(user); return { user: toPublicUser(user), ...tokens }; } +/** + * Redeems an admin invite and signs the new admin in (Phase 8, issue #39). + * + * The invite itself belongs to the users module β€” this module only hashes + * the password and issues the session, calling that module's service rather + * than its repository (AGENTS.md hard rule 4). + */ +export interface SignupWithInviteInput { + token: string; + email: string; + password: string; +} + +export async function signupWithInvite(input: SignupWithInviteInput): Promise { + const passwordHash = await hashPassword(input.password); + const user = await redeemAdminInvite({ token: input.token, email: input.email, passwordHash }); + + const tokens = issueSession(user); + return { + user: { + id: user.id, + email: user.email, + role: user.role, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + ...tokens, + }; +} + export async function refresh(refreshToken: string): Promise { let payload; try { @@ -104,6 +142,42 @@ export async function refresh(refreshToken: string): Promise { const user = await findUserById(payload.sub); if (!user) throw new HttpError(401, 'Invalid or expired refresh token.'); + // Deactivated accounts can't refresh out of their deactivation, and a + // session revoked by deactivation/reactivation can't come back to life + // through a refresh cookie either (Phase 8, issue #39). + if (user.deactivatedAt) throw new HttpError(403, 'This account has been deactivated.'); + if (isSessionRevoked(user.sessionsInvalidatedAt, payload.iat)) { + throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); + } + const tokens = issueSession(user); return { user: toPublicUser(user), ...tokens }; } + +/** + * Guards every authenticated request (Phase 8, issue #39). The access token + * is otherwise stateless, so a deactivated account's token would keep + * working for up to 15 minutes; this is the DB read that closes that gap. + * + * Deliberately narrow: it answers "is this account still active, and was + * this token issued after the last deactivation/reactivation", not "is this + * token on a revocation list" β€” a general revocation list is issue #18 and + * is still unbuilt. Because it's a per-request read, tokens carrying no + * `iat` are treated as revoked once `sessionsInvalidatedAt` is set (fail + * closed). + */ +export async function assertActiveSession(userId: string, issuedAtSeconds?: number): Promise { + const user = await findUserById(userId); + if (!user) throw new HttpError(401, 'Invalid or expired access token.'); + if (user.deactivatedAt) throw new HttpError(401, 'This account has been deactivated.'); + if (isSessionRevoked(user.sessionsInvalidatedAt, issuedAtSeconds)) { + throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); + } +} + +function isSessionRevoked(sessionsInvalidatedAt: Date | null, issuedAtSeconds: number | undefined): boolean { + if (!sessionsInvalidatedAt) return false; + // `iat` has second precision, so a token minted in the same second as the + // invalidation counts as revoked β€” the safe direction. + return (issuedAtSeconds ?? 0) * 1000 < sessionsInvalidatedAt.getTime(); +} diff --git a/backend/src/modules/auth/services/tokens.ts b/backend/src/modules/auth/services/tokens.ts index ba342c7..bea1507 100644 --- a/backend/src/modules/auth/services/tokens.ts +++ b/backend/src/modules/auth/services/tokens.ts @@ -5,6 +5,10 @@ import type { Role } from '@prisma/client'; export interface AccessTokenPayload { sub: string; // user id role: Role; + /** Issued-at, set by `jsonwebtoken` itself. Read by the auth middleware to + * compare against `User.sessionsInvalidatedAt` (Phase 8, issue #39) β€” + * tokens minted before a deactivation must stop working immediately. */ + iat?: number; } export function signAccessToken(payload: AccessTokenPayload): string { diff --git a/backend/src/modules/auth/validation.ts b/backend/src/modules/auth/validation.ts index 1e8a048..dc1c9f3 100644 --- a/backend/src/modules/auth/validation.ts +++ b/backend/src/modules/auth/validation.ts @@ -11,3 +11,11 @@ export const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1), }); + +// Phase 8, issue #39 β€” admin invite redemption. No `fullName`: an admin +// account has no Patient/Doctor profile row. +export const signupWithInviteSchema = z.object({ + token: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), +}); diff --git a/backend/src/modules/users/controllers/index.ts b/backend/src/modules/users/controllers/index.ts index 385b97c..29b9f05 100644 --- a/backend/src/modules/users/controllers/index.ts +++ b/backend/src/modules/users/controllers/index.ts @@ -1,2 +1,45 @@ -// users controllers β€” placeholder. -export {}; +// users controllers β€” admin account lifecycle (Phase 8, issue #39). +import type { Request, Response } from 'express'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; +import * as usersService from '../services'; +import { createAdminInviteSchema } from '../validation'; + +/** + * Creates a single-use, expiring admin invite. + * + * The raw token is returned in this response **once** β€” only its hash is + * stored, so it can't be fetched again β€” and is never logged. The inviting + * admin is responsible for passing the link to the invitee out of band; + * there is no email provider yet (Phase 6, issue #17). + */ +export async function createInvite(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { email } = createAdminInviteSchema.parse(req.body); + const invite = await usersService.createAdminInvite({ email, invitedByUserId: req.user.id }); + // Logged without the token: the invite link is a credential. + // eslint-disable-next-line no-console + console.info(`[users] admin ${req.user.id} created an invite for ${invite.email} (expires ${invite.expiresAt.toISOString()})`); + res.status(201).json(invite); +} + +/** + * Deactivates an account. There is no `AuditEvent` table yet (see + * ARCHITECTURE_ESSENTIALS.md known gaps), so β€” like the login-throttle clear + * endpoint β€” the console line is the interim record of who did this. + */ +export async function deactivate(req: Request, res: Response) { + const userId = getParam(req, 'id'); + const user = await usersService.deactivateUser({ userId }); + // eslint-disable-next-line no-console + console.info(`[users] admin ${req.user?.id ?? 'unknown'} deactivated user ${userId}`); + res.status(200).json(user); +} + +export async function reactivate(req: Request, res: Response) { + const userId = getParam(req, 'id'); + const user = await usersService.reactivateUser({ userId }); + // eslint-disable-next-line no-console + console.info(`[users] admin ${req.user?.id ?? 'unknown'} reactivated user ${userId}`); + res.status(200).json(user); +} diff --git a/backend/src/modules/users/repositories/index.ts b/backend/src/modules/users/repositories/index.ts index 2402cb1..5a1005c 100644 --- a/backend/src/modules/users/repositories/index.ts +++ b/backend/src/modules/users/repositories/index.ts @@ -1,3 +1,105 @@ // users repositories β€” placeholder. Data access only. Not to be imported // by other modules directly β€” go through services/ instead. -export {}; +// +// Phase 8 (issue #39): this module owns the shared `User` row and admin +// account lifecycle (invites, deactivation). `auth` still owns credentials +// and sessions β€” it calls into these services for user creation, never the +// other way round. +import { prisma } from '../../../db/prisma-client'; +import type { Role } from '@prisma/client'; + +export function findUserById(id: string) { + return prisma.user.findUnique({ where: { id } }); +} + +export function findUserByEmail(email: string) { + return prisma.user.findUnique({ where: { email } }); +} + +/** Active = not deactivated. Used for the "last active admin" guard. */ +export function countActiveUsersByRole(role: Role) { + return prisma.user.count({ where: { role, deactivatedAt: null } }); +} + +export function setUserDeactivated(userId: string, deactivatedAt: Date) { + return prisma.user.update({ + where: { id: userId }, + data: { deactivatedAt, sessionsInvalidatedAt: deactivatedAt }, + }); +} + +export function setUserReactivated(userId: string, at: Date) { + return prisma.user.update({ + where: { id: userId }, + data: { + deactivatedAt: null, + // Also bumped on reactivation: tokens issued before deactivation must + // not come back to life because the account was re-enabled. + sessionsInvalidatedAt: at, + }, + }); +} + +// --- Admin invites (Phase 8, issue #39) ---------------------------------- + +/** Creates the invite and retires any outstanding invite for the same + * address, in one transaction β€” issuing a new link shouldn't leave two live + * ones. Retiring means setting `consumedAt`, never deleting. */ +export function replaceAdminInvite(input: { + email: string; + tokenHash: string; + expiresAt: Date; + invitedByUserId: string; +}) { + return prisma.$transaction(async (tx) => { + await tx.adminInvite.updateMany({ + where: { email: input.email, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + return tx.adminInvite.create({ + data: { + email: input.email, + tokenHash: input.tokenHash, + expiresAt: input.expiresAt, + invitedByUserId: input.invitedByUserId, + }, + }); + }); +} + +export function findAdminInviteByTokenHash(tokenHash: string) { + return prisma.adminInvite.findUnique({ where: { tokenHash } }); +} + +/** Consumes the invite and creates the admin account atomically: a + * consumed invite with no account (or an account from an already-consumed + * invite) must never be observable. Returns null if the invite was already + * consumed by a concurrent redemption β€” see services/redeemAdminInvite. + * + * TODO(issue #34): when email verification lands, an invited/bootstrapped + * admin should be created already verified β€” an invite is an admin-vouched + * address, and there is no email provider to verify against until issue + * #17. The `emailVerifiedAt` column doesn't exist on this branch, so it + * can't be set here yet; wire it up in the PR that merges both. */ +export function consumeAdminInviteAndCreateAdmin(input: { + inviteId: string; + email: string; + passwordHash: string; + consumedAt: Date; +}) { + return prisma.$transaction(async (tx) => { + const consumed = await tx.adminInvite.updateMany({ + where: { id: input.inviteId, consumedAt: null }, + data: { consumedAt: input.consumedAt }, + }); + if (consumed.count !== 1) return null; + + return tx.user.create({ + data: { + email: input.email, + passwordHash: input.passwordHash, + role: 'admin', + }, + }); + }); +} diff --git a/backend/src/modules/users/routes/index.ts b/backend/src/modules/users/routes/index.ts index 329b96c..c2b810c 100644 --- a/backend/src/modules/users/routes/index.ts +++ b/backend/src/modules/users/routes/index.ts @@ -1,3 +1,20 @@ -// users routes β€” placeholder. See PRD.md / ARCHITECTURE.md Β§3 for this module's scope. -// TODO(Phase: see ROADMAP.md): define routes for users. -export {}; +// users routes β€” admin account lifecycle (Phase 8, issue #39). Mounted at +// `/admin` in app.ts; every route is admin-only. +import { Router } from 'express'; +import { authenticate, requireRole } from '../../../middleware/auth'; +import * as usersController from '../controllers'; + +export const adminRouter = Router(); + +adminRouter.use(authenticate, requireRole('admin')); + +// Invite a new admin. Returns the invite token once β€” only its hash is +// stored. Redemption happens at `POST /auth/signup-with-invite` (auth's +// module: it owns sessions), which calls this module's service to do the +// invite part. +adminRouter.post('/invites', usersController.createInvite); + +// Soft deactivation: refuses only for the last active admin, and never +// deletes clinical or payment records (nothing is keyed to this flag). +adminRouter.patch('/users/:id/deactivate', usersController.deactivate); +adminRouter.patch('/users/:id/reactivate', usersController.reactivate); diff --git a/backend/src/modules/users/services/index.ts b/backend/src/modules/users/services/index.ts index e95698a..1dc286b 100644 --- a/backend/src/modules/users/services/index.ts +++ b/backend/src/modules/users/services/index.ts @@ -1,3 +1,167 @@ -// users services β€” placeholder. Other modules must only import from here, -// never from repositories/ directly. See AGENTS.md "Non-negotiable rules". -export {}; +// users services β€” the only exported surface other modules (and this +// module's own controllers) may call. Never import repositories/* from +// outside this module. See AGENTS.md "Non-negotiable rules". +// +// Phase 8 (issue #39): admin account lifecycle β€” invites and deactivation. +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import * as usersRepo from '../repositories'; +import { generateAdminInviteToken, hashAdminInviteToken } from './invite-tokens'; +import type { Role } from '@prisma/client'; + +export interface PublicUser { + id: string; + email: string; + role: Role; + deactivatedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type UserRow = { + id: string; + email: string; + role: Role; + deactivatedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +function toPublicUser(user: UserRow): PublicUser { + return { + id: user.id, + email: user.email, + role: user.role, + deactivatedAt: user.deactivatedAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} + +// --- Invites ------------------------------------------------------------- + +export interface CreateAdminInviteInput { + email: string; + invitedByUserId: string; +} + +export interface CreatedAdminInvite { + id: string; + email: string; + expiresAt: Date; + /** + * The raw invite token, returned exactly once β€” only its hash is stored, + * so it can never be retrieved again. There is no email provider yet + * (issue #17), so the inviting admin has to pass the link on out of band; + * both are deliberately never written to a log. + */ + token: string; + inviteUrl: string; +} + +export async function createAdminInvite(input: CreateAdminInviteInput): Promise { + const email = input.email.trim(); + if (!email) throw new HttpError(400, 'An email address is required.'); + + const existing = await usersRepo.findUserByEmail(email); + if (existing) { + // Deliberately not "an account exists": that's already visible from the + // signup endpoint, and an admin inviting a colleague needs the real + // reason rather than a coin flip. Refusing here keeps the invite table + // to addresses that can actually be redeemed. + throw new HttpError(409, 'An account with this email already exists β€” deactivate or promote it instead of inviting it.'); + } + + const token = generateAdminInviteToken(); + const expiresAt = new Date(Date.now() + config.adminInvites.ttlHours * 60 * 60 * 1000); + const invite = await usersRepo.replaceAdminInvite({ + email, + tokenHash: hashAdminInviteToken(token), + expiresAt, + invitedByUserId: input.invitedByUserId, + }); + + return { + id: invite.id, + email: invite.email, + expiresAt: invite.expiresAt, + token, + inviteUrl: `${config.adminInvites.acceptUrl}?token=${encodeURIComponent(token)}`, + }; +} + +export interface RedeemAdminInviteInput { + token: string; + email: string; + passwordHash: string; +} + +/** Called by the auth module's `POST /auth/signup-with-invite` β€” the invite + * is the users module's business, the session is auth's. Returns the created + * admin account, or throws a 400/403 that says nothing about which invite + * tokens exist beyond "this one isn't usable". */ +export async function redeemAdminInvite(input: RedeemAdminInviteInput): Promise { + const invite = await usersRepo.findAdminInviteByTokenHash(hashAdminInviteToken(input.token)); + if (!invite) throw new HttpError(400, 'Invalid admin invite token.'); + if (invite.consumedAt) throw new HttpError(400, 'This invite has already been used.'); + if (invite.expiresAt.getTime() <= Date.now()) { + throw new HttpError(400, 'This invite has expired. Ask an admin for a new one.'); + } + if (invite.email.toLowerCase() !== input.email.trim().toLowerCase()) { + throw new HttpError(403, 'This invite was issued for a different email address.'); + } + + const user = await usersRepo.consumeAdminInviteAndCreateAdmin({ + inviteId: invite.id, + email: input.email.trim(), + passwordHash: input.passwordHash, + consumedAt: new Date(), + }); + + // Lost a race with a concurrent redemption of the same invite. + if (!user) throw new HttpError(400, 'This invite has already been used.'); + + return toPublicUser(user); +} + +// --- Deactivation -------------------------------------------------------- + +export interface DeactivateInput { + userId: string; +} + +/** + * Deactivates an account: it can no longer log in, refresh, or use an + * existing access token (see auth's `assertActiveSession`). + * + * Nothing is deleted. No clinical or payment record is keyed to this flag, + * and this function only ever writes `deactivatedAt` / + * `sessionsInvalidatedAt` β€” that's the whole point of a soft deactivation + * (AGENTS.md hard rules on append-only/mutability of order records). + */ +export async function deactivateUser(input: DeactivateInput): Promise { + const user = await usersRepo.findUserById(input.userId); + if (!user) throw new HttpError(404, 'User not found.'); + if (user.deactivatedAt) return toPublicUser(user); // idempotent + + // The admin console is the only way back in, so the last active admin + // must not be able to lock everyone out of it (explicit in issue #39). + if (user.role === 'admin') { + const activeAdmins = await usersRepo.countActiveUsersByRole('admin'); + if (activeAdmins <= 1) { + throw new HttpError(409, 'Cannot deactivate the last active admin.'); + } + } + + const updated = await usersRepo.setUserDeactivated(user.id, new Date()); + return toPublicUser(updated); +} + +export async function reactivateUser(input: DeactivateInput): Promise { + const user = await usersRepo.findUserById(input.userId); + if (!user) throw new HttpError(404, 'User not found.'); + if (!user.deactivatedAt) return toPublicUser(user); // idempotent + + const updated = await usersRepo.setUserReactivated(user.id, new Date()); + return toPublicUser(updated); +} diff --git a/backend/src/modules/users/services/invite-tokens.ts b/backend/src/modules/users/services/invite-tokens.ts new file mode 100644 index 0000000..446040d --- /dev/null +++ b/backend/src/modules/users/services/invite-tokens.ts @@ -0,0 +1,14 @@ +import { createHash, randomBytes } from 'node:crypto'; + +/** The invite token that goes in the link an admin hands over. Never + * persisted, never logged β€” only its hash is. */ +export function generateAdminInviteToken(): string { + return randomBytes(32).toString('base64url'); +} + +/** SHA-256 (not bcrypt): this is 256 bits of CSPRNG output, so there is + * nothing to brute-force, and hashing must be cheap because it happens on + * every redemption attempt. */ +export function hashAdminInviteToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} diff --git a/backend/src/modules/users/validation.ts b/backend/src/modules/users/validation.ts new file mode 100644 index 0000000..c33c5d2 --- /dev/null +++ b/backend/src/modules/users/validation.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +// Phase 8, issue #39. Deactivate/reactivate take no body β€” the target is the +// path parameter. +export const createAdminInviteSchema = z.object({ + email: z.string().email(), +}); diff --git a/backend/tests/integration/admin-lifecycle.test.mts b/backend/tests/integration/admin-lifecycle.test.mts new file mode 100644 index 0000000..1345ae5 --- /dev/null +++ b/backend/tests/integration/admin-lifecycle.test.mts @@ -0,0 +1,347 @@ +// Integration tests for the admin account lifecycle (Phase 8, issue #39): +// the bootstrap CLI, invites (hashed/expiring/single-use), deactivation +// (login + refresh + live access tokens all refused), the last-active-admin +// guard, and the promise that deactivation deletes nothing. +// +// Self-contained embedded Postgres, own port (5438) so it runs alongside the +// other integration files. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5438; +const runId = Date.now(); +const PASSWORD = 'longenough123'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-admin-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +function cookiePair(res: { headers: Record }, name: string): string | null { + const raw = res.headers['set-cookie']; + const list = Array.isArray(raw) ? (raw as string[]) : typeof raw === 'string' ? [raw] : []; + const match = list.find((cookie) => cookie.startsWith(`${name}=`)); + return match ? match.split(';')[0]! : null; +} + +/** Runs the bootstrap CLI the way an operator would: password on stdin, + * never in argv. */ +function runCreateAdmin(args: string[], stdin: string) { + try { + const stdout = execFileSync('node', ['scripts/create-admin.mjs', ...args], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + input: stdin, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { status: 0, stdout, stderr: '' }; + } catch (err) { + const failure = err as { status?: number; stdout?: string; stderr?: string }; + return { status: failure.status ?? 1, stdout: failure.stdout ?? '', stderr: failure.stderr ?? '' }; + } +} + +describe('admin account lifecycle', () => { + let app: App; + let prisma: Prisma; + let bootstrapAdminEmail: string; + let bootstrapAdminToken: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + async function signupPatient(label: string) { + const email = `admin-lifecycle-${label}-${runId}@example.com`; + const res = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email, password: PASSWORD, fullName: `Admin Lifecycle ${label}` }); + expect(res.status).toBe(200); + return { email, accessToken: res.body.accessToken as string, cookie: cookiePair(res, 'docforum_refresh')!, raw: res }; + } + + async function loginAs(email: string, password = PASSWORD) { + const res = await request(app).post('/auth/login').send({ email, password }); + expect(res.status).toBe(200); + return { accessToken: res.body.accessToken as string, cookie: cookiePair(res, 'docforum_refresh')!, raw: res }; + } + + it('bootstraps an admin from the CLI, refuses a second without --force, and rejects passwords in argv', async () => { + bootstrapAdminEmail = `bootstrap-admin-${runId}@example.com`; + const created = runCreateAdmin([`--email=${bootstrapAdminEmail}`], `${PASSWORD}\n`); + expect(created.status).toBe(0); + + const admin = await prisma.user.findUniqueOrThrow({ where: { email: bootstrapAdminEmail } }); + expect(admin.role).toBe('admin'); + expect(admin.deactivatedAt).toBeNull(); + // The password is hashed, and does not appear in the CLI's output. + expect(admin.passwordHash).not.toContain(PASSWORD); + expect(created.stdout).not.toContain(PASSWORD); + expect(created.stdout).not.toContain(admin.passwordHash); + + // A stray second run must not quietly add another administrator. + const refused = runCreateAdmin([`--email=second-admin-${runId}@example.com`], `${PASSWORD}\n`); + expect(refused.status).not.toBe(0); + expect(refused.stderr).toMatch(/--force/); + expect(await prisma.user.count({ where: { email: `second-admin-${runId}@example.com` } })).toBe(0); + + const forced = runCreateAdmin([`--email=second-admin-${runId}@example.com`, '--force'], `${PASSWORD}\n`); + expect(forced.status).toBe(0); + + // Credentials never travel in argv (`ps`/history/CI logs). + const argvRefused = runCreateAdmin([`--email=third-${runId}@example.com`, `--password=${PASSWORD}`], ''); + expect(argvRefused.status).not.toBe(0); + expect(argvRefused.stderr).toMatch(/command-line arguments/); + expect(await prisma.user.count({ where: { email: `third-${runId}@example.com` } })).toBe(0); + + bootstrapAdminToken = (await loginAs(bootstrapAdminEmail)).accessToken; + }); + + it('issues a hashed, expiring, single-use invite that redeems into an admin session', async () => { + const inviteeEmail = `invited-admin-${runId}@example.com`; + + const created = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${bootstrapAdminToken}`) + .send({ email: inviteeEmail }); + expect(created.status).toBe(201); + + const token = created.body.token as string; + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(created.body.email).toBe(inviteeEmail); + + // Stored hashed, never raw, with a future expiry. + const stored = await prisma.adminInvite.findFirstOrThrow({ where: { email: inviteeEmail } }); + expect(stored.tokenHash).not.toBe(token); + expect(stored.tokenHash).toMatch(/^[a-f0-9]{64}$/); + expect(stored.expiresAt.getTime()).toBeGreaterThan(Date.now()); + expect(stored.consumedAt).toBeNull(); + + const redeemed = await request(app) + .post('/auth/signup-with-invite') + .send({ token, email: inviteeEmail, password: PASSWORD }); + expect(redeemed.status).toBe(200); + expect(redeemed.body.user.role).toBe('admin'); + + const invite = await prisma.adminInvite.findFirstOrThrow({ where: { email: inviteeEmail } }); + expect(invite.consumedAt).not.toBeNull(); + + // Single-use: the same link can't mint a second admin. + const replay = await request(app) + .post('/auth/signup-with-invite') + .send({ token, email: inviteeEmail, password: PASSWORD }); + expect(replay.status).toBe(400); + expect(await prisma.user.count({ where: { email: inviteeEmail } })).toBe(1); + + // And the new admin really is an admin. + const asNewAdmin = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${redeemed.body.accessToken as string}`) + .send({ email: `invited-by-new-admin-${runId}@example.com` }); + expect(asNewAdmin.status).toBe(201); + }); + + it('refuses invites from non-admins, for a different address, and once expired', async () => { + const patient = await signupPatient('invite-authz'); + const asPatient = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ email: `whatever-${runId}@example.com` }); + expect(asPatient.status).toBe(403); + + // Existing accounts aren't invitable either β€” that's a promote, + // not an invite. + const forExisting = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${bootstrapAdminToken}`) + .send({ email: patient.email }); + expect(forExisting.status).toBe(409); + + const wrongAddressEmail = `wrong-address-${runId}@example.com`; + const invite = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${bootstrapAdminToken}`) + .send({ email: wrongAddressEmail }); + expect(invite.status).toBe(201); + + const mismatched = await request(app) + .post('/auth/signup-with-invite') + .send({ token: invite.body.token as string, email: `someone-else-${runId}@example.com`, password: PASSWORD }); + expect(mismatched.status).toBe(403); + + // Expiry: age the stored invite rather than waiting it out. + const expiringEmail = `expiring-${runId}@example.com`; + const expiring = await request(app) + .post('/admin/invites') + .set('Authorization', `Bearer ${bootstrapAdminToken}`) + .send({ email: expiringEmail }); + await prisma.adminInvite.updateMany({ + where: { email: expiringEmail }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + + const expired = await request(app) + .post('/auth/signup-with-invite') + .send({ token: expiring.body.token as string, email: expiringEmail, password: PASSWORD }); + expect(expired.status).toBe(400); + expect(await prisma.user.count({ where: { email: expiringEmail } })).toBe(0); + }); + + it('deactivation blocks login, refresh and existing access tokens, and reactivation lets them back in', async () => { + const patient = await signupPatient('deactivate'); + const patientRow = await prisma.user.findUniqueOrThrow({ where: { email: patient.email } }); + + // The access token works before deactivation. + const before = await request(app) + .get('/appointments/mine') + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(before.status).toBe(200); + + const deactivated = await request(app) + .patch(`/admin/users/${patientRow.id}/deactivate`) + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(deactivated.status).toBe(200); + expect(deactivated.body.deactivatedAt).not.toBeNull(); + + // Login is refused… + const login = await request(app).post('/auth/login').send({ email: patient.email, password: PASSWORD }); + expect(login.status).toBe(403); + + // …refresh is refused (the refresh cookie is still valid)… + const refresh = await request(app) + .post('/auth/refresh') + .set('Cookie', patient.cookie); + expect(refresh.status).toBe(403); + + // …and the access token that was already in the client's hand stops + // working immediately, rather than living out its 15-minute TTL. + const after = await request(app) + .get('/appointments/mine') + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(after.status).toBe(401); + + // Reactivation: logging in works again… + const reactivated = await request(app) + .patch(`/admin/users/${patientRow.id}/reactivate`) + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(reactivated.status).toBe(200); + expect(reactivated.body.deactivatedAt).toBeNull(); + + const freshLogin = await loginAs(patient.email); + const withFreshToken = await request(app) + .get('/appointments/mine') + .set('Authorization', `Bearer ${freshLogin.accessToken}`); + expect(withFreshToken.status).toBe(200); + + // …but the pre-deactivation token stays dead: sessions invalidated by a + // deactivation must not come back to life with the account. + const reused = await request(app) + .get('/appointments/mine') + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(reused.status).toBe(401); + + // A wrong password is still just a 401, and a missing user is a 404. + const missing = await request(app) + .patch('/admin/users/00000000-0000-0000-0000-000000000000/deactivate') + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(missing.status).toBe(404); + }); + + it('refuses to deactivate the last active admin', async () => { + // Leave exactly one active admin: the two bootstrapped ones, minus one. + const admins = await prisma.user.findMany({ where: { role: 'admin', deactivatedAt: null }, select: { id: true, email: true } }); + expect(admins.length).toBeGreaterThanOrEqual(2); + const [keep, ...others] = admins; + for (const other of others) { + await prisma.user.update({ where: { id: other.id }, data: { deactivatedAt: new Date() } }); + } + + const refused = await request(app) + .patch(`/admin/users/${keep!.id}/deactivate`) + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(refused.status).toBe(409); + expect(refused.body.message).toMatch(/last active admin/i); + + const stillActive = await prisma.user.findUniqueOrThrow({ where: { id: keep!.id } }); + expect(stillActive.deactivatedAt).toBeNull(); + }); + + it('deactivation deletes no clinical or payment records', async () => { + // A booked appointment is the strongest thing this phase can check β€” + // there are no Prescription/LabOrder/PaymentIntent rows to protect yet. + const doctor = `doctor-${runId}@example.com`; + const doctorSignup = await request(app) + .post('/auth/signup') + .send({ role: 'doctor', email: doctor, password: PASSWORD, fullName: 'Deactivation Test Doctor' }); + expect(doctorSignup.status).toBe(200); + const doctorToken = doctorSignup.body.accessToken as string; + + const doctorMe = await request(app).get('/doctors/me').set('Authorization', `Bearer ${doctorToken}`); + const doctorProfileId = doctorMe.body.id as string; + + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const generate = await request(app) + .post(`/availability/doctors/${doctorProfileId}/slots/generate`) + .set('Authorization', `Bearer ${doctorToken}`) + .send({ date: tomorrow, startHour: 9, endHour: 10, slotLengthMinutes: 30 }); + expect(generate.status).toBe(201); + const slots = await request(app).get(`/availability/doctors/${doctorProfileId}/slots`); + + const patient = await signupPatient('records'); + const booking = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slots.body[0].id as string }); + expect(booking.status).toBe(201); + + const patientRow = await prisma.user.findUniqueOrThrow({ where: { email: patient.email } }); + const deactivated = await request(app) + .patch(`/admin/users/${patientRow.id}/deactivate`) + .set('Authorization', `Bearer ${bootstrapAdminToken}`); + expect(deactivated.status).toBe(200); + + // The appointment and the slot it consumed are untouched. + const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: booking.body.id as string } }); + expect(appointment.patientProfileId).toBeTruthy(); + const slot = await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: appointment.availabilitySlotId } }); + expect(slot.status).toBe('booked'); + }); +}); diff --git a/backend/tests/unit/admin-invite-tokens.test.ts b/backend/tests/unit/admin-invite-tokens.test.ts new file mode 100644 index 0000000..2007047 --- /dev/null +++ b/backend/tests/unit/admin-invite-tokens.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + generateAdminInviteToken, + hashAdminInviteToken, +} from '../../src/modules/users/services/invite-tokens'; + +describe('admin invite tokens (issue #39)', () => { + it('generates a fresh, URL-safe token each time', () => { + const tokens = new Set(Array.from({ length: 50 }, () => generateAdminInviteToken())); + expect(tokens.size).toBe(50); + for (const token of tokens) { + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + } + }); + + it('hashes deterministically and never stores the raw token', () => { + const token = generateAdminInviteToken(); + const hash = hashAdminInviteToken(token); + expect(hash).toBe(hashAdminInviteToken(token)); + expect(hash).toMatch(/^[a-f0-9]{64}$/); + expect(hash).not.toContain(token); + }); + + it('produces different hashes for different tokens', () => { + expect(hashAdminInviteToken(generateAdminInviteToken())).not.toBe(hashAdminInviteToken(generateAdminInviteToken())); + }); +}); diff --git a/docs/adr/0003-render-preview-deployment.md b/docs/adr/0003-render-preview-deployment.md index 881b8d2..0b2f6cc 100644 --- a/docs/adr/0003-render-preview-deployment.md +++ b/docs/adr/0003-render-preview-deployment.md @@ -80,10 +80,24 @@ backend is down between steps 2 and 4. 5. Check the deploy log shows the migrations applied, then sign up a test user through the `docforum-web` Pages preview to confirm the round trip (including the refresh cookie). -6. Re-create the admin/demo accounts from step 1. `POST /auth/signup` - refuses `role: 'admin'`, so sign up as a patient, then promote it - with the database's **External Database URL**: - `psql "" -c "UPDATE \"User\" SET role = 'admin' WHERE email = '';"` +6. Re-create the admin/demo accounts from step 1. Use the bootstrap CLI + added in issue #39 (it needs the database URL, so run it with the + **External Database URL** in `DATABASE_URL`): + + ``` + cd backend + DATABASE_URL="" node scripts/create-admin.mjs --email + ``` + + It prompts for the password (never takes it in argv, never prints it), and + refuses to add a second admin unless `--force` is passed. For a second/demo + admin, invite one instead: `POST /admin/invites` as the first admin, then + redeem the returned token at `POST /auth/signup-with-invite`. See + `scripts/README.md`. + + (Before issue #39 this step was `POST /auth/signup` as a patient followed by + a hand-written `UPDATE "User" SET role = 'admin'` β€” that's the path this + replaced.) 7. Update the expiry date at the top of this section (and in `ROADMAP.md` Phase 9). diff --git a/docs/api/README.md b/docs/api/README.md index aa21154..f957efb 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -13,7 +13,8 @@ spec yet β€” TBD, tracked informally, not blocking Phase 1. | Method & path | Auth | Body | Notes | |---|---|---|---| | `POST /auth/signup` | none | `{ role: 'patient'\|'doctor', email, password, fullName }` | **`role` is restricted to `patient`/`doctor` server-side, not just in docforum-web's UI** β€” facility accounts are admin-invited only (PRD OQ-2). Returns `{ user, accessToken }`; sets an httpOnly refresh cookie. | -| `POST /auth/login` | none | `{ email, password }` | Same response shape as signup. | +| `POST /auth/login` | none | `{ email, password }` | Same response shape as signup. `403` for a deactivated account β€” checked *after* the password, so it doesn't leak which addresses exist. | +| `POST /auth/signup-with-invite` | none | `{ token, email, password }` | Redeems an admin invite (Phase 8, issue #39) and signs the new admin in β€” same response shape as signup. `400` for an unknown/expired/already-used token, `403` if the invite was issued for a different address. | | `POST /auth/refresh` | refresh cookie | β€” | Reads the httpOnly cookie (not the body). Returns a fresh `{ user, accessToken }` and rotates the cookie. | | `POST /auth/logout` | none | β€” | Clears the refresh cookie. `204 No Content`. | @@ -45,6 +46,29 @@ choice**, see the comment above `setRefreshCookie` in | `POST /appointments` | patient | `{ availabilitySlotId }` | **The concurrency-critical endpoint** β€” see `ARCHITECTURE.md` Β§5.1 and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. | | `GET /appointments/mine` | patient | β€” | Caller's own appointments, most recent first. | +## Admin account lifecycle (`/admin`) β€” `backend/src/modules/users` + +Phase 8, issue #39. Every route here is admin-only (`authenticate` + +`requireRole('admin')`); a non-admin gets `403`. + +| Method & path | Body | Notes | +|---|---|---| +| `POST /admin/invites` | `{ email }` | `201` with `{ id, email, expiresAt, token, inviteUrl }`. The raw `token` is returned **once** β€” only its SHA-256 hash is stored, so it can never be fetched again, and it is never logged. The inviting admin passes the link on out of band (there's no email provider yet β€” issue #17). `409` if the address already has an account. Issuing a new invite for the same address retires the previous one. | +| `PATCH /admin/users/:id/deactivate` | β€” | Soft deactivation: sets `deactivatedAt`, refuses login/refresh/existing access tokens. `409 Cannot deactivate the last active admin.` Idempotent. **Deletes nothing** β€” no clinical or payment record is keyed to this flag. | +| `PATCH /admin/users/:id/reactivate` | β€” | Clears `deactivatedAt`. Returns the user; the pre-deactivation session tokens stay revoked. | + +Admins are created either by the bootstrap CLI +(`node backend/scripts/create-admin.mjs`, see `scripts/README.md`) or by +invite. `POST /auth/signup` still refuses `role: 'admin'` server-side. + +Who-did-what is written to the server console (`[users] admin +deactivated user `): there is no `AuditEvent` table yet (see +`ARCHITECTURE_ESSENTIALS.md` known gaps), so that's the interim record. + +Env: `ADMIN_INVITE_TTL_HOURS` (default 72), +`ADMIN_INVITE_ACCEPT_URL` (where the invite link points β€” docforum-web owns +that page). + ## Not yet implemented Everything else in `ARCHITECTURE.md` Β§3's module list (`patients`, diff --git a/scripts/README.md b/scripts/README.md index e230958..9557dda 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,3 +1,70 @@ # scripts -One-off/dev scripts (seeding, migrations helpers, etc.) go here. None yet. +One-off/dev/operator scripts. The ones that need this repo's Prisma schema +or Stellar SDK live next to the backend that uses them +(`backend/scripts/`) β€” this file is where they're documented, so there's one +place to look. + +## `backend/scripts/create-admin.mjs` + +**The supported way to seed an admin account** (Phase 8, issue #39). Before +this, the only path was hand-editing the `User` row in the Render database β€” +see `docs/adr/0003-render-preview-deployment.md`, whose renewal runbook now +uses this script. + +``` +node backend/scripts/create-admin.mjs --email admin@example.com +printf 'admin@example.com\n\n' | node backend/scripts/create-admin.mjs +node backend/scripts/create-admin.mjs --email admin@example.com --force +``` + +Needs `DATABASE_URL` (it reads `backend/.env` via `dotenv/config`, so run it +from `backend/`, or export the URL). + +Rules it exists to enforce: + +- **Credentials never come from argv.** `ps`, shell history and CI logs all + record command lines, so the password is read from stdin β€” silently, when + there's a TTY. Passing `--password`/`--password-hash` is refused rather + than quietly ignored. The email *is* accepted as `--email`, because an + address isn't a secret. +- **Secrets are never printed or logged.** No password, no hash, no echo. +- **It refuses to run when an active admin already exists**, unless `--force` + is passed, so a stray run can't quietly add a second administrator. +- **It refuses to overwrite an existing account** for the same address β€” + promote or reactivate that one instead. + +Piped input is `email` then `password` (one line each, no confirmation +line); interactive input prompts for all three. + +## `backend/scripts/mark-email-verified.mjs` + +**Interim (Phase 8, issue #34).** Marks an existing account's email address +as verified, out-of-band: + +``` +node backend/scripts/mark-email-verified.mjs +node backend/scripts/mark-email-verified.mjs # prompts for the email +``` + +Why it exists: no email provider is integrated yet (Phase 6, issue #17), so +an account created through `POST /auth/signup` on a hosted deployment can't +complete the verification-link flow at all. This is the documented operator +path until a provider lands β€” it needs database credentials, which is the +same access a manual SQL `UPDATE` would require. Delete it when issue #17 +lands. + +## `backend/scripts/gen-platform-identities.mjs` + +Generates a fresh testnet Stellar payer + releaser keypair and funds them via +Friendbot, writing the secrets straight to `backend/.env` (never printed). +See `docs/adr/0004-custodial-payments-v1.md`. + +## `backend/scripts/gen-migration.mjs` + +Generates a `prisma/migrations/_/migration.sql` from a real +Postgres diff without needing a local dev DB (spins up a throwaway embedded +Postgres as Prisma's shadow database). Usage: +`node scripts/gen-migration.mjs ` β€” review the generated SQL +before committing, and add any data backfill it can't express as a schema +diff. From c8d0c4e08435ccef458059b23525424be9c45ad8 Mon Sep 17 00:00:00 2001 From: techAlhaji Date: Mon, 19 Oct 2026 11:14:31 +0000 Subject: [PATCH 2/4] fix(ci): restore admin-lifecycle code dropped by the main merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four "Merge branch 'main'" commits on this PR silently resolved conflicts in main's favour, deleting the issue #39 feature code while keeping its tests and routes: the AdminInvite model and User deactivatedAt/sessionsInvalidatedAt columns vanished from schema.prisma (their migration stayed), signupWithInvite / assertActiveSession / isSessionRevoked / signupWithInviteSchema / config.adminInvites were dropped, and the /admin adminRouter mount was replaced by the audit router. Restores all of it on top of main's current code, plus the two Prisma 7 adaptations the merge could not know about: create-admin.mjs now builds its client with the PrismaPg driver adapter (a bare PrismaClient throws in v7), and the post-merge integration test verifies the patient's email before booking, because main's #34 gate now guards that endpoint. Also defers the fresh login in the deactivation test past the second-precision iat boundary, which made the test flaky under load. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- backend/prisma/schema.prisma | 33 ++++++++++ backend/scripts/create-admin.mjs | 10 ++- backend/src/app.ts | 4 ++ backend/src/config/index.ts | 6 ++ backend/src/middleware/auth.ts | 2 +- backend/src/modules/auth/controllers/index.ts | 8 ++- backend/src/modules/auth/services/index.ts | 63 +++++++++++++++++++ backend/src/modules/auth/validation.ts | 8 +++ .../integration/admin-lifecycle.test.mts | 15 +++++ 9 files changed, 146 insertions(+), 3 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7cfb28b..57c4357 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -109,6 +109,17 @@ model User { // DoctorProfile.verificationStatus, which is the separate, manual // admin verification of a doctor's credentials β€” do not conflate. emailVerifiedAt DateTime? + // Phase 8 (issue #39): null = active. Deactivating never deletes anything + // β€” no clinical or payment record is keyed to this flag, and none of the + // deactivation paths remove rows. Login, refresh and access tokens are all + // refused while this is set. + deactivatedAt DateTime? + // Phase 8 (issue #39): bumped when an account is deactivated/reactivated, + // so access tokens minted before that moment stop working immediately + // instead of living out their 15-minute TTL. There is still no general + // refresh-token revocation list (issue #18) β€” this is the narrow "revoke + // everything for one account" case that deactivation needs. + sessionsInvalidatedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -117,6 +128,7 @@ model User { facilityProfile FacilityProfile? emailVerificationTokens EmailVerificationToken[] + adminInvitesSent AdminInvite[] @@index([role]) } @@ -138,6 +150,27 @@ model EmailVerificationToken { @@index([userId]) } +// Phase 8 (issue #39): admin invites. Admin accounts are "seeded or +// invited" (ROADMAP Phase 8) and there was no supported path for either. +// Only the SHA-256 hash of the token is stored β€” a database leak must not +// hand out working invite links β€” and every invite is single-use and +// expiring. Admin-only scope for now: PRD OQ-2's facility invites will need +// their own profile fields (name/type) at redemption, so they are +// deliberately not folded in here. +model AdminInvite { + id String @id @default(uuid()) + email String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + invitedByUserId String + createdAt DateTime @default(now()) + + invitedBy User @relation(fields: [invitedByUserId], references: [id], onDelete: Cascade) + + @@index([email]) +} + // Phase 8 (issue #35): login-throttle state. Deliberately in Postgres and // not in process memory β€” the backend runs as a single Render instance // today but must not assume that forever, and a restart must not hand an diff --git a/backend/scripts/create-admin.mjs b/backend/scripts/create-admin.mjs index 7caf1ed..f3acaf8 100644 --- a/backend/scripts/create-admin.mjs +++ b/backend/scripts/create-admin.mjs @@ -23,11 +23,19 @@ import 'dotenv/config'; import bcrypt from 'bcryptjs'; import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; const SALT_ROUNDS = 12; const MIN_PASSWORD_LENGTH = 8; -const prisma = new PrismaClient(); +// Prisma 7 (ADR 0006) requires a driver adapter β€” `new PrismaClient()` with +// no adapter throws. Same shape as src/db/prisma-client.ts, minus the +// server-only config import (this script runs standalone from the shell). +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL, + connectionTimeoutMillis: 5_000, +}); +const prisma = new PrismaClient({ adapter }); function fail(message) { console.error(`create-admin: ${message}`); diff --git a/backend/src/app.ts b/backend/src/app.ts index 0397740..834f950 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; import { paymentsRouter } from './modules/payments/routes'; import { patientsRouter } from './modules/patients/routes'; +import { adminRouter } from './modules/users/routes'; import { registerPatientExportSections } from './export-sections'; import { auditRouter } from './modules/audit/routes'; import { requestId } from './middleware/request-id'; @@ -42,6 +43,9 @@ export function createApp() { app.use('/appointments', appointmentsRouter); app.use('/payments', paymentsRouter); app.use('/patients', patientsRouter); + // Phase 8, issue #39 β€” admin account lifecycle (invites, deactivate/ + // reactivate) alongside the audit read path. Different paths, no overlap. + app.use('/admin', adminRouter); app.use('/admin', auditRouter); // Must be registered last β€” Express only routes errors here once every diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 9a01e58..7c2b0c0 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -129,6 +129,12 @@ export const config = { sameSite: process.env.NODE_ENV === 'production' ? ('none' as const) : ('lax' as const), path: '/', }, + // Admin invites (Phase 8, issue #39). `acceptUrl` is where the invite link + // points β€” docforum-web owns the page that consumes `?token=`. + adminInvites: { + ttlHours: Number(process.env.ADMIN_INVITE_TTL_HOURS ?? 72), + acceptUrl: process.env.ADMIN_INVITE_ACCEPT_URL ?? 'http://localhost:5173/accept-admin-invite', + }, // Deliberately NOT required at startup via requireEnv() β€” unrelated // parts of the app (auth, availability, etc.) shouldn't fail to boot // just because payments isn't configured. The payments module itself diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index f615ab5..8ede224 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,5 +1,5 @@ import type { NextFunction, Request, Response } from 'express'; -import { assertEmailVerified, verifyAccessToken } from '../modules/auth/services'; +import { assertActiveSession, assertEmailVerified, verifyAccessToken } from '../modules/auth/services'; import { HttpError } from '../http-error'; import type { Role } from '@prisma/client'; diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts index ec0ec5b..8111bb2 100644 --- a/backend/src/modules/auth/controllers/index.ts +++ b/backend/src/modules/auth/controllers/index.ts @@ -1,6 +1,12 @@ import type { Request, Response } from 'express'; import * as authService from '../services'; -import { clearLoginAttemptsSchema, loginSchema, signupSchema, verifyEmailSchema } from '../validation'; +import { + clearLoginAttemptsSchema, + loginSchema, + signupSchema, + signupWithInviteSchema, + verifyEmailSchema, +} from '../validation'; import { HttpError } from '../../../http-error'; import { config } from '../../../config'; import { readCsrfHeader } from '../../../middleware/csrf'; diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index ef7da93..455c87b 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -26,6 +26,7 @@ import { resetLoginThrottle, } from './login-throttle'; import { csrfTokensMatch, generateCsrfToken } from '../../../middleware/csrf'; +import { redeemAdminInvite } from '../../users/services'; import type { Role } from '@prisma/client'; import type { PatientExportSection } from '../../patients/services'; @@ -142,6 +143,40 @@ export async function signup(input: SignupInput): Promise { return { user: toPublicUser(user), ...tokens }; } +/** + * Redeems an admin invite and signs the new admin in (Phase 8, issue #39). + * + * The invite itself belongs to the users module β€” this module only hashes + * the password and issues the session, calling that module's service rather + * than its repository (AGENTS.md hard rule 4). + */ +export interface SignupWithInviteInput { + token: string; + email: string; + password: string; +} + +export async function signupWithInvite(input: SignupWithInviteInput): Promise { + const passwordHash = await hashPassword(input.password); + const user = await redeemAdminInvite({ token: input.token, email: input.email, passwordHash }); + + const tokens = issueSession(user); + return { + user: { + id: user.id, + email: user.email, + role: user.role, + // No verification email is sent on the invite path β€” the invite itself + // is the admin-vetted proof of address, so this stays null rather than + // claiming a verification that never happened. + emailVerifiedAt: null, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + ...tokens, + }; +} + export interface LoginInput { email: string; password: string; @@ -343,6 +378,34 @@ export async function refresh(refreshToken: string, csrfToken?: string | null): return { user: toPublicUser(user), ...tokens }; } +/** + * Guards every authenticated request (Phase 8, issue #39). The access token + * is otherwise stateless, so a deactivated account's token would keep + * working for up to 15 minutes; this is the DB read that closes that gap. + * + * Deliberately narrow: it answers "is this account still active, and was + * this token issued after the last deactivation/reactivation", not "is this + * token on a revocation list" β€” a general revocation list is issue #18 and + * is still unbuilt. Because it's a per-request read, tokens carrying no + * `iat` are treated as revoked once `sessionsInvalidatedAt` is set (fail + * closed). + */ +export async function assertActiveSession(userId: string, issuedAtSeconds?: number): Promise { + const user = await findUserById(userId); + if (!user) throw new HttpError(401, 'Invalid or expired access token.'); + if (user.deactivatedAt) throw new HttpError(401, 'This account has been deactivated.'); + if (isSessionRevoked(user.sessionsInvalidatedAt, issuedAtSeconds)) { + throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); + } +} + +function isSessionRevoked(sessionsInvalidatedAt: Date | null, issuedAtSeconds: number | undefined): boolean { + if (!sessionsInvalidatedAt) return false; + // `iat` has second precision, so a token minted in the same second as the + // invalidation counts as revoked β€” the safe direction. + return (issuedAtSeconds ?? 0) * 1000 < sessionsInvalidatedAt.getTime(); +} + /** This module's contribution to the patient data export (issue #40): the * login identity itself. Explicit allowlist β€” never the password hash, and * no verification/throttle bookkeeping. */ diff --git a/backend/src/modules/auth/validation.ts b/backend/src/modules/auth/validation.ts index 9d958c5..e992454 100644 --- a/backend/src/modules/auth/validation.ts +++ b/backend/src/modules/auth/validation.ts @@ -12,6 +12,14 @@ export const loginSchema = z.object({ password: z.string().min(1), }); +// Phase 8, issue #39 β€” admin invite redemption. No `fullName`: an admin +// account has no Patient/Doctor profile row. +export const signupWithInviteSchema = z.object({ + token: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), +}); + // Phase 8, issue #34. No `email` field on the resend body on purpose β€” the // resend endpoint is authenticated and targets the caller's own account, // so it can't be used to probe which addresses exist. diff --git a/backend/tests/integration/admin-lifecycle.test.mts b/backend/tests/integration/admin-lifecycle.test.mts index 1345ae5..bfdb7de 100644 --- a/backend/tests/integration/admin-lifecycle.test.mts +++ b/backend/tests/integration/admin-lifecycle.test.mts @@ -265,6 +265,13 @@ describe('admin account lifecycle', () => { expect(reactivated.status).toBe(200); expect(reactivated.body.deactivatedAt).toBeNull(); + // `iat` is second-precision and `isSessionRevoked` deliberately counts a + // token minted in the same second as the invalidation as revoked (the + // safe direction). A login immediately after reactivation can therefore + // mint a same-second token that is correctly refused β€” wait out the + // second boundary so the fresh token is genuinely fresh. + await new Promise((resolve) => setTimeout(resolve, 1100)); + const freshLogin = await loginAs(patient.email); const withFreshToken = await request(app) .get('/appointments/mine') @@ -326,6 +333,14 @@ describe('admin account lifecycle', () => { const slots = await request(app).get(`/availability/doctors/${doctorProfileId}/slots`); const patient = await signupPatient('records'); + // Booking is gated on a verified email (Phase 8, issue #34 β€” merged in + // from main after this test was written). Deletion-resistance is the + // subject here, not verification, so the fixture marks the patient + // verified directly rather than replaying the emailed-token flow. + await prisma.user.update({ + where: { email: patient.email }, + data: { emailVerifiedAt: new Date() }, + }); const booking = await request(app) .post('/appointments') .set('Authorization', `Bearer ${patient.accessToken}`) From 5f3e8cb4a638469ef8acf0bfb226b1afadb99adf Mon Sep 17 00:00:00 2001 From: techAlhaji Date: Mon, 19 Oct 2026 11:20:26 +0000 Subject: [PATCH 3/4] fix(test): raise vitest hookTimeout for embedded-Postgres beforeAll hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration hooks do more than a single test β€” start an embedded Postgres, run `prisma migrate deploy`, import the app, seed actors β€” and several such files run in parallel. Vitest's 10s default hook timeout cut the access-control seed off mid-flight on the CI runner (101 tests skipped), even though testTimeout was already raised to 15s for exactly this class of slowness. 30s matches the same reasoning. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- backend/vitest.config.mts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/vitest.config.mts b/backend/vitest.config.mts index c500601..b184413 100644 --- a/backend/vitest.config.mts +++ b/backend/vitest.config.mts @@ -7,6 +7,12 @@ export default defineConfig({ // Booking-concurrency tests fire real concurrent transactions against // a real Postgres β€” a bit more headroom than vitest's 5s default. testTimeout: 15_000, + // The integration beforeAll hooks do even more than one test: start an + // embedded Postgres, run `prisma migrate deploy`, import the app and + // seed actors. Several such files run in parallel, so on a loaded CI + // runner the 10s default cut the hook off mid-seed (issue #39 CI run: + // access-control timed out at 101 tests skipped). + hookTimeout: 30_000, env: { JWT_ACCESS_SECRET: 'test-access-secret', JWT_REFRESH_SECRET: 'test-refresh-secret', From 8f00e5aab561f9801bd2dcdefd7152d39f5daf37 Mon Sep 17 00:00:00 2001 From: techAlhaji Date: Mon, 19 Oct 2026 11:27:57 +0000 Subject: [PATCH 4/4] fix(test): give every integration file its own embedded-Postgres port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit access-control, payments-bigint and admin-lifecycle all bound port 5438. Two concurrent embedded Postgres clusters on one port hang each other's beforeAll until vitest's hook timeout kills the file β€” exactly what the CI run showed (access-control: hook timed out, 101 tests skipped). The collision existed between the first two on main; adding the restored admin-lifecycle file made it reliable. admin-lifecycle moves to 5439, payments-bigint to 5440 β€” each integration file now owns a port. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- backend/tests/integration/admin-lifecycle.test.mts | 4 ++-- backend/tests/integration/payments-bigint.test.mts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/tests/integration/admin-lifecycle.test.mts b/backend/tests/integration/admin-lifecycle.test.mts index bfdb7de..5973da2 100644 --- a/backend/tests/integration/admin-lifecycle.test.mts +++ b/backend/tests/integration/admin-lifecycle.test.mts @@ -3,7 +3,7 @@ // (login + refresh + live access tokens all refused), the last-active-admin // guard, and the promise that deactivation deletes nothing. // -// Self-contained embedded Postgres, own port (5438) so it runs alongside the +// Self-contained embedded Postgres, own port (5439) so it runs alongside the // other integration files. import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; @@ -15,7 +15,7 @@ import request from 'supertest'; import EmbeddedPostgres from 'embedded-postgres'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5438; +const PORT = 5439; const runId = Date.now(); const PASSWORD = 'longenough123'; diff --git a/backend/tests/integration/payments-bigint.test.mts b/backend/tests/integration/payments-bigint.test.mts index 7ef3eea..1b4c0a3 100644 --- a/backend/tests/integration/payments-bigint.test.mts +++ b/backend/tests/integration/payments-bigint.test.mts @@ -28,7 +28,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import EmbeddedPostgres from 'embedded-postgres'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5438; +const PORT = 5440; // unique per integration file β€” 5438 collides with access-control const runId = Date.now(); // 2^53 + 1 β€” exactly one more than Number.MAX_SAFE_INTEGER, so it only