Skip to content

feat(admin): secure bootstrap CLI, admin invites, and account deactivation - #141

Merged
afeezorobsco-cyber merged 9 commits into
DocForum:mainfrom
techAlhaji:feat/admin-account-lifecycle-secure-bootstrap-cli
Oct 19, 2026
Merged

afeezorobsco-cyber merged 9 commits into
DocForum:mainfrom
techAlhaji:feat/admin-account-lifecycle-secure-bootstrap-cli

Conversation

@techAlhaji

Copy link
Copy Markdown
Contributor

What changed

Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation (Phase 8). Closes #39.

Admin accounts are "seeded or invited" according to the roadmap, but neither path actually existed — the preview admin was recreated by hand with UPDATE "User" SET role = 'admin' against the Render database, and there was no way to shut down a compromised or departed doctor/facility/admin account without deleting rows.

1. backend/scripts/create-admin.mjs — the supported bootstrap path

  • Credentials never come from argv. ps, shell history and CI logs 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: an address isn't a secret).
  • Nothing secret is printed or logged — not the password, not the hash, no echo. The test asserts the hash isn't in the CLI's stdout.
  • Refuses to run when an active admin already exists, unless --force is passed, so a stray run can't quietly add a second administrator.
  • Refuses to overwrite an existing account for the same address — that's a promote/reactivate, not a create.
  • Piped input is email then password (no confirmation line); interactive mode prompts for all three.

2. Admin invites

  • AdminInvite model (migration 20261015175817_add_admin_lifecycle): SHA-256 token_hash only, expiresAt, consumedAt. The raw token is never persisted and never logged.
  • POST /admin/invites (admin-only) → 201 { id, email, expiresAt, token, inviteUrl }. The token is returned once; there's no email provider yet (issue [Phase 6] ADR: pick notification provider (email/SMS) #17), so the inviting admin passes the link on out of band. Issuing a new invite for the same address retires the outstanding one (in one transaction, consumedAt-marked, never deleted).
  • POST /auth/signup-with-invite redeems it and signs the new admin in. Consuming the invite and creating the account happen in a single transaction, with the consume as a conditional UPDATE ... WHERE consumedAt IS NULL, so two concurrent redemptions can't both mint an admin.
  • 409 if the address already has an account, 403 if the invite belongs to a different address, 400 for unknown/used/expired tokens.
  • Module boundary note: the invite is the users module's business and the session is auth's, so authService.signupWithInvite calls usersService.redeemAdminInvite (service-to-service, hard rule 4) and never touches AdminInvite directly.
  • Admin-only for now. PRD OQ-2's facility invites need FacilityProfile name/type at redemption, so folding them in here would have mixed two account shapes into one endpoint — noted rather than half-built.

3. Deactivation

  • User.deactivatedAt + User.sessionsInvalidatedAt; PATCH /admin/users/:id/deactivate and /reactivate (both admin-only, both idempotent).
  • Login, refresh and existing access tokens are all refused. The refresh cookie is checked in refresh(), and authenticate now calls assertActiveSession(userId, iat) — a per-request DB read that rejects a deactivated account and any token minted before the account's sessions were invalidated. Without it an access token would keep working for the rest of its 15-minute TTL. /reactivate bumps sessionsInvalidatedAt too, so a token from before the deactivation doesn't come back to life when the account does (the integration test asserts exactly that). Tokens with no iat fail closed.
    • This is deliberately narrow — "is this account active, and was this token minted after the last invalidation" — not a general refresh-token revocation list. That's still issue [Phase 8] JWT refresh-token revocation #18; the code says so rather than implying the gap is closed.
    • Cost: one indexed primary-key read per authenticated request. Called out here because it's the kind of thing that should be a conscious trade-off.
  • Last active admin guard: deactivating the last active admin is 409 (the admin console is the only way back in).
  • Deletes nothing. The service only writes the two flags; no clinical or payment record is keyed to them. Tested by booking a real appointment, deactivating the patient, and asserting the appointment and its consumed slot are untouched.
  • 403 for a deactivated login is returned after the password check, so it can't be used to probe which addresses exist. Console audit lines record who deactivated whom (no AuditEvent table yet — known gap — so that's the interim record).

4. Docs, including the runbook this replaces

scripts/README.md documents the CLI (the issue's DoD) and ADR 0003's renewal runbook step 6 now uses it instead of the hand-written SQL promotion.

Assumptions (issue didn't specify)

  • Invite TTL 72 h (ADMIN_INVITE_TTL_HOURS), accept URL configurable (ADMIN_INVITE_ACCEPT_URL) — docforum-web owns that page.
  • Invites are admin-only; facility invites stay future work (see above).
  • PATCH /admin/users/:id/deactivate on an already-deactivated user returns 200 unchanged rather than erroring (idempotent).
  • Deactivating yourself isn't special-cased except by the last-admin guard; if other admins exist it succeeds.

Integration note for #34 (email verification)

PR #34 adds User.emailVerifiedAt, which doesn't exist on main, so the CLI and the invite redemption here can't mark a fresh admin verified yet. Both places carry a TODO(issue #34) and the PR that merges second should create bootstrap/invited admins as verified — otherwise a new admin can't call the payment endpoints that #34 gates. Flagging it rather than letting it surface after both land.

Which ROADMAP.md item does this close/progress?

Phase 8 — "Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation", issue #39.

Docs updated in this PR

  • ROADMAP.md (Phase 8 status + new item + changelog)
  • ARCHITECTURE.md (§4.1 User fields + AdminInvite; §3 users module scope)
  • ARCHITECTURE_ESSENTIALS.md (data model map)
  • docs/api/README.md (new /auth/signup-with-invite + /admin/* section)
  • scripts/README.md (CLI documented, as the DoD requires) and ADR 0003's renewal runbook
  • backend/.env.example (ADMIN_INVITE_*)
  • PRD.md — N/A

Test output

> npm test
 ✓ tests/unit/validation.test.ts (7 tests)
 ✓ tests/unit/tokens.test.ts (4 tests)
 ✓ tests/unit/password.test.ts (3 tests)
 ✓ tests/unit/admin-invite-tokens.test.ts (3 tests)
 Test Files  4 passed (4)
      Tests  17 passed (17)

> npm run test:integration
 ✓ tests/integration/admin-lifecycle.test.mts (6 tests)
     ✓ bootstraps an admin from the CLI, refuses a second without --force, and rejects passwords in argv
     ✓ issues a hashed, expiring, single-use invite that redeems into an admin session
     ✓ refuses invites from non-admins, for a different address, and once expired
     ✓ deactivation blocks login, refresh and existing access tokens, and reactivation lets them back in
     ✓ refuses to deactivate the last active admin
     ✓ deactivation deletes no clinical or payment records
 ✓ tests/integration/booking.test.mts (1 test)
 Test Files  2 passed (2)
      Tests  7 passed (7)

npm run typecheck clean.

The CLI test runs the real script as a subprocess against the test database (password piped on stdin, --force, and argv-password refusal), so the DoD's "secrets never appear in process args or logs" is verified by execution rather than inspection. The new integration file uses its own embedded Postgres on port 5438, so it runs in parallel with the other integration files; no change to vitest.config.mts.

Closes #39

Note on parallel Phase 8 PRs

#34, #35 and #37 touch the same auth module. This PR touches app.ts, config, middleware/auth.ts, auth/{routes,controllers,services,validation} and the whole users module. Overlap with the others is mostly additive (a route registration line, a claim on the token payload, ARCHITECTURE.md §4.1/§6, docs/api/README.md), so a small rebase may be needed if one of those merges first — plus the TODO(issue #34) integration note above. This PR deliberately does not touch http-error.ts or middleware/csrf.ts.

…ation

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 <noreply@codebuff.com>
@drips-wave

drips-wave Bot commented Oct 15, 2026

Copy link
Copy Markdown

@techAlhaji Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@techAlhaji pls fix CI

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

CI is failing on this PR. main itself is green, so the cause is in this branch:

Typecheck fails: src/middleware/auth.ts(37,9): Cannot find name 'assertActiveSession'. The function exists in src/modules/auth/services/index.ts:357 but middleware/auth.ts never imports it. Add the import. This branch is also 3 commits behind main, so please rebase or merge main too.

Please push a fix and CI will re-run. Before pushing, run npm run prisma:generate && npm run typecheck && npm test && npm run test:integration in backend/.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@techAlhaji

This PR has merge conflicts with main, which has moved on since this branch was created. Please rebase onto the latest main (or merge main into your branch), resolve the conflicts, and push. CI will then re-run, and the PR can be merged once it's conflict-free, CI is green and it's been reviewed. Thanks!

techAlhaji and others added 5 commits October 19, 2026 11:32
The four "Merge branch 'main'" commits on this PR silently resolved
conflicts in main's favour, deleting the issue DocForum#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 DocForum#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 <noreply@codebuff.com>
…ooks

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 <noreply@codebuff.com>
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 <noreply@codebuff.com>
@afeezorobsco-cyber
afeezorobsco-cyber merged commit 2368bb8 into DocForum:main Oct 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Phase 8] Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation

2 participants