Skip to content

Nate-Soul/Authenticare

Repository files navigation

ARC Node.js Boilerplate

A production-grade, typed Node.js + Express API template, built to the ARC backend standard. Clone it, delete what you don't need, and build your product's modules on top of the auth/users foundation that's already here.

Built for TypeScript. A note on TypeScript 7: TS7 (the native Go-ported compiler) hit GA on July 8, 2026 with identical type-checking semantics to TS 6.0, just far faster. This template currently pins typescript@6.0.3 rather than 7, because as of this writing @typescript-eslint's entire 8.x line fails to load against TS7 (it reaches into TS compiler-internal APIs that changed shape in the Go port) — not just a peer-range warning, an actual crash on npm run lint. Since type-checking semantics are unchanged, nothing about the app's correctness depends on which one you use. Once @typescript-eslint ships a compatible release (worth checking — it shipped a day after this template was written), bump typescript to ^7.0.0 in package.json; nothing else changes.

Why this exists

This started as a review of a small auth API (Authenticare https://github.com/Nate-Soul/Authenticare) that had good bones but real bugs: a password re-hashing bug that silently broke logins on profile updates, and a public endpoint that returned every user's password hash. This template fixes those classes of bug at the architecture level — not just patches them — so a new project built on it starts from a correct foundation instead of inheriting the same mistakes.

What's included

  • Swappable data layer — a UserRepository interface with both a MongoDB (Mongoose) and a PostgreSQL (Prisma) implementation behind it. Services depend only on the interface, so they're testable with an in-memory fake and the database is a configuration choice, not an architecture choice.
  • Real auth — short-lived access tokens (15m) + rotating refresh tokens (30d) in an httpOnly cookie. Reusing a stale refresh token is rejected; logout revokes every outstanding refresh token via a tokenVersion counter.
  • Real RBACrequireRole() and requireSelfOrAdmin() middleware, backed by an actual role field on the user, unlike a same-named check that only verified ownership.
  • Validation everywhere — every route body/params/query is parsed through Zod before it reaches a controller.
  • Rate limiting — a general API limiter plus a tighter one on /auth/* to blunt brute force.
  • Consistent responses — every success is { success: true, data, meta? }, every error is { success: false, error: { code, message, details? } }.
  • Soft delete everywhere in the data layer instead of destructive deletes.
  • Fail-fast configsrc/config/env.ts validates process.env with Zod at boot; the app refuses to start with a missing or malformed secret instead of failing weirdly at request time.
  • Tests — Vitest unit tests against a fake repository (no DB required) plus a Supertest integration smoke test, including regression tests for both original bugs.
  • Email verification & password reset — DB-stored, single-use, SHA-256-hashed tokens with expiry; the mailer and queue are both pluggable so the flow works with zero infra by default.
  • Pluggable mailerMAIL_DRIVER=console (default, logs the email — no SMTP needed) or MAIL_DRIVER=smtp (nodemailer, loaded via dynamic import only when selected).
  • Pluggable queueQUEUE_DRIVER=inline (default, in-process, no Redis needed) or QUEUE_DRIVER=bullmq (Redis-backed, loaded via dynamic import only when selected). Email sends run through the queue so they never block the request path.
  • OpenAPI docs generated from the Zod schemas — no hand-maintained spec to drift from the validation. See "API docs" below.

Getting started

cp .env.example .env          # then fill in real secrets
npm install --legacy-peer-deps  # see "TypeScript 7" note above for why
docker compose up -d mongo    # or `postgres`, matching DB_DRIVER in .env
npm run prisma:generate       # only needed if you're using the postgres driver — requires network access to fetch the query engine binary
npm run dev

API docs

Interactive Swagger UI is served at GET /api/v1/docs; the raw OpenAPI 3.0 document is at GET /api/v1/docs.json. It's generated from the same Zod schemas that validate requests, so it can't drift from the actual validation rules. Currently covers the auth module — see src/shared/openapi/registry.ts for how to register additional modules.

Auth endpoints

Method Path Auth required Purpose
POST /api/v1/auth/register Create an account; sends a verification email
POST /api/v1/auth/login Exchange credentials for access + refresh tokens
POST /api/v1/auth/refresh refresh cookie Rotate the refresh token, issue a new access token
POST /api/v1/auth/logout access token Revoke all outstanding refresh tokens (tokenVersion bump)
POST /api/v1/auth/verify-email Consume a verification token, mark the account verified
POST /api/v1/auth/resend-verification Re-issue a verification email (always 200 — no enumeration)
POST /api/v1/auth/forgot-password Issue a password-reset email (always 200 — no enumeration)
POST /api/v1/auth/reset-password Consume a reset token, set a new password, revoke all sessions

Gate any route on a verified email with the requireVerified() middleware (src/shared/middleware/auth.ts) — it runs after requireAuth and returns 403 EMAIL_NOT_VERIFIED otherwise.

Mailer & queue drivers

Both default to zero-infra, in-process implementations so the template runs with nothing but npm install. Swap in real infra by env var only — no code changes:

Concern Env var Default Alternative
Mailer MAIL_DRIVER console (logs the email body, no SMTP) smtp (nodemailer; requires SMTP_HOST, SMTP_PORT, optionally SMTP_USER/SMTP_PASS/SMTP_SECURE)
Queue QUEUE_DRIVER inline (in-process, no Redis) bullmq (Redis-backed; requires REDIS_URL)

Both real-infra drivers (smtp.mailer.ts, bullmq.queue.ts) are loaded via dynamic import() only when selected, so nodemailer/bullmq never touch the module graph — and therefore never open a socket — unless you opt in. See src/shared/email/ and src/shared/queue/.

Using this as a template for a new project

  1. Pick a database and delete the other driver.

    • Delete src/shared/db/mongo/ or src/shared/db/postgres/.
    • In src/shared/db/index.ts, delete the branch you don't need and export the repository directly.
    • Remove DB_DRIVER from src/config/env.ts — it becomes a hardcoded fact, not a runtime switch.
    • Remove the unused dependency (mongoose, or @prisma/client + prisma) from package.json.
    • Delete the matching service from docker-compose.yml.
  2. Rename the domain. modules/auth and modules/users are the reference implementation — keep them, since almost everything needs auth, but add your product's real modules alongside:

    src/modules/
    ├── auth/          (keep)
    ├── users/          (keep)
    ├── your-domain/    (new — controller/service/routes/schemas, same shape)
    └── index.ts
    

    Register new routes in src/routes/v1/index.ts.

  3. Extend the User entity if needed (e.g. add avatarUrl, phone) in both src/shared/types/user.ts and whichever driver you kept — the interface is the contract, update it first and let the type errors show you every place that needs a change.

  4. Generate real secrets before deploying:

    node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
  5. Drop what you don't need. If the product has no email flows, delete src/shared/email/, src/shared/queue/, the verify-email/resend-verification/forgot-password/reset-password routes, and the nodemailer/bullmq dependencies. If you keep email but never need a real queue, delete src/shared/queue/bullmq.queue.ts and the bullmq dependency — inline.queue.ts has no external dependency and needs nothing removed.

Project structure

src/
├── modules/                # Business domains (auth, users, ...)
│   └── <domain>/
│       ├── <domain>.controller.ts   # HTTP layer only
│       ├── <domain>.service.ts      # business logic, depends on interfaces only
│       ├── <domain>.routes.ts
│       ├── <domain>.schemas.ts      # Zod
│       └── index.ts
├── routes/v1/               # Route aggregation + health check
├── shared/
│   ├── db/                  # Swappable data layer (mongo/, postgres/, index.ts)
│   ├── middleware/           # auth, rbac, rate limiting, validation, error handling
│   ├── types/                # Domain types + Express request augmentation
│   └── utils/                # AppError, response envelope, tokens, logger, asyncHandler
├── config/env.ts             # Zod-validated environment config
├── app.ts                    # Express app (no listen() — importable for tests)
└── server.ts                 # Entrypoint: connect DB, listen, graceful shutdown
tests/
├── unit/                     # Service tests against FakeUserRepository
└── integration/              # Supertest against the real app, no DB needed for these routes

Scripts

Command Purpose
npm run dev Start with hot reload (tsx watch)
npm run build / start Compile and run the production build
npm run typecheck tsc --noEmit
npm run lint / lint:fix ESLint
npm test / test:watch / test:coverage Vitest
npm run prisma:generate / prisma:migrate Only relevant if you kept the Postgres driver

Deliberate deviations from the raw ARC template

  • Added a repository-pattern data layer (shared/db/{mongo,postgres}) instead of a single shared/db.ts, specifically to support the dual-driver requirement — document this choice if you diverge further.
  • No websocket layer included; add shared/websocket/ per the ARC template when a project actually needs it. A queue layer (shared/queue/) is included since the email flows depend on it — see "Mailer & queue drivers" above.

Known limitations

  • Single-session refresh. tokenVersion is one counter per user, not per device/session. Logging out, or a password reset, revokes every refresh token for that user — there's no way to revoke one device's session while leaving others logged in. Add a per-session token table if you need that.
  • Soft-deleted emails hold the unique index. Deleting a user sets deletedAt rather than removing the row, but the email uniqueness constraint is on the raw column in both drivers. A soft-deleted account's email can't be reused by a new signup. If you need that, either hard delete on account removal or move to a partial/filtered unique index (Postgres: @@unique with a WHERE deleted_at IS NULL condition; Mongo: a partial index with { deletedAt: null }).

About

Implementation of an extensible authentication and authorization system using JSON Web Tokens (JWTs) and cookies in an Express.js application

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages