Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ See `PRD.md` for product scope and `ARCHITECTURE_ESSENTIALS.md` for the quick-re
Each module under `backend/src/modules/<name>/` 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).
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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`
Expand Down
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ PATIENT_EXPORT_MAX_ATTEMPTS=3
# 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

33 changes: 33 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -117,6 +128,7 @@ model User {
facilityProfile FacilityProfile?

emailVerificationTokens EmailVerificationToken[]
adminInvitesSent AdminInvite[]

@@index([role])
}
Expand All @@ -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
Expand Down
178 changes: 178 additions & 0 deletions backend/scripts/create-admin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// 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<password>\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';
import { PrismaPg } from '@prisma/adapter-pg';

const SALT_ROUNDS = 12;
const MIN_PASSWORD_LENGTH = 8;

// 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}`);
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());
4 changes: 4 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 16 additions & 8 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,22 +13,30 @@ declare global {
}

/** Verifies the `Authorization: Bearer <accessToken>` 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 EMAIL_NOT_VERIFIED`
Expand Down
20 changes: 19 additions & 1 deletion backend/src/modules/auth/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -70,6 +76,18 @@ export async function clearLoginAttempts(req: Request, res: Response) {
res.status(200).json({ cleared });
}

/**
* 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<string, string> | undefined)?.[REFRESH_COOKIE];
if (!token) throw new HttpError(401, 'No refresh token provided.');
Expand Down
Loading
Loading