Skip to content

Introduction

Keelmint is a full-stack SaaS foundation in one Nx monorepo: a NestJS REST API on Postgres (Prisma) and an Angular client built on spartan/ui and Tailwind. It gives you the parts every B2B product needs before its first real feature, so you start on your product’s own screens on day one. Read this page to see what is inside; then follow the Quickstart.

You get: sign-up with email verification, Google login, password reset, optional TOTP two-factor, multi-tenancy (organizations and units, with self-service signup), roles and permissions (shared and per organization), invitations, Stripe subscriptions with plan limits, a helpdesk, an audit log, live in-app notifications, a background job queue, English/Greek i18n, light/dark themes, Docker deployment and a CI pipeline. Everything is covered by tests.

Core and products. The shared foundation lives in libs/core/* and never changes per product. A product is your app: an API, one or more clients and a database, generated with one command and built on core (see Your product). The repo ships one example product, keelmint, a small todo list that shows an owned, permission-guarded, organization- and unit-scoped resource and the shared list/table pattern.

Tech stack

LayerChoiceVersion (from package.json)
MonorepoNx, pnpmNx 23.1.1, pnpm 11.18.0
RuntimeNode.js24 (Dockerfile and CI)
LanguageTypeScript6.0.3
APINestJS (Express), Passport JWT, SwaggerNestJS 11
API buildtsdown (oxc/rolldown), single main.cjs bundle0.22
DatabasePostgreSQL with Prisma (@prisma/adapter-pg)Prisma 7.9.1, Postgres 17 (Docker/CI)
Jobspg-boss on the same Postgres12
Mailnodemailer over SMTP, Mailpit for developmentnodemailer 10
Validationclass-validator on the API, zod for envzod 4
ClientAngular (standalone, signals)22.0
UIspartan/ui (Brain + Helm), Tailwind CSS, lucide iconsspartan 1.5, Tailwind 4
i18nTransloco8
Loggingpino / nestjs-pinopino 10
TestsJest, jest-preset-angularJest 30

Authentication and security

  • Sessions: a short-lived JWT access token (default 15 minutes, JWT_ACCESS_TTL) kept in memory on the client, plus an opaque refresh token in an HttpOnly, SameSite=Lax, Path=/api/auth cookie (Secure in production). Refresh tokens rotate on every use and are stored server-side as a chain, so replaying a rotated token is detected as theft and recorded in the audit log (with a 30-second grace window for concurrent refreshes). Logout revokes the session. The number of concurrent sessions per user is capped (MAX_ACTIVE_SESSIONS_PER_USER, default 10). Expired rows are cleaned up by a scheduled job. See Authentication.
  • Passwords: hashed with argon2 (@node-rs/argon2). Login verifies against a dummy hash for unknown emails, so the response does not reveal whether an account exists.
  • Email verification: registering sends a link through the mail queue and issues no session until it is followed. Login answers 403 EMAIL_NOT_VERIFIED before then. Resend is limited to 1 per 60 s and 5 per hour per user. See Authentication.
  • Password reset: forgot-password always answers 202 and responds before the lookup (no timing or response difference for unknown emails). Reset links are valid for 1 hour, single-use, and a successful reset revokes every session.
  • Two-factor authentication (TOTP, RFC 6238): QR enrollment in the profile, ten single-use recovery codes stored only as keyed HMAC-SHA256, secrets sealed with AES-256-GCM (TWO_FACTOR_ENCRYPTION_KEY), replay protection per time step, a login challenge that is short-lived and attempt-limited, and one per-user failure budget (sliding 15-minute window) that locks both login and the code-checking routes. Enabling, disabling and resetting sign out other sessions, notify the user in-app and by email, and are audited. A platform admin can reset a user’s 2FA. See Two-factor authentication.
  • Rate limiting: per-IP throttling on the public auth routes (THROTTLE_LIMIT, default 5 per minute), per-user caps on resend and forgot-password, and the 2FA failure budget above.
  • HTTP hardening: Helmet (CSP relaxed only for Swagger UI), CORS that fails closed unless CORS_ORIGIN is set, a global ValidationPipe with whitelist and forbidNonWhitelisted.
  • Soft delete: users, roles, organizations, memberships, units, todos, invitations and helpdesk tickets are never hard-deleted. A Prisma extension adds the isDeleted filter and turns plain delete on those models into an error; use softDelete / softDeleteMany. Shared fields (id, timestamps, isDeleted) come from one base.prisma file composed into the schema.
  • Audit log: an append-only audit_events table recording more than 20 security and administration actions (registration, login success/failure, refresh reuse, password change/reset, role and user changes, invitations, 2FA events). Each event carries actor, target, metadata, IP and request id. Most events are written in the same transaction as the change, so a failed audit write rolls the change back. Platform admins search, sort by time, filter and page it at /audit (the shared table; a row’s details open in a dialog) or GET /api/audit-events.
  • Log redaction: the request log records method, path (no query string), status, duration and user id. It never records headers, bodies, cookies, query strings or email addresses.

Users, roles and permissions

  • Roles: four built-in system roles in a hierarchy - PLATFORM_ADMIN (everything), ORG_ADMIN (an organization), UNIT_ADMIN (a unit and its sub-units), USER (the default) - plus any the platform admin creates, the same in every organization and managed on /admin/roles (your code gives only their initial permissions); an organization admin can add the organization’s own roles. A member holds one or more role assignments in an organization (never zero), each org-wide or scoped to a unit and its descendants. Nobody can grant or take away a permission they do not hold.
  • Permission catalog: generated from the code, served at GET /api/permissions, and enforced per route with a guard. A missing permission returns 403 PERMISSION_REQUIRED with the permission name in details.
  • Platform admin and default role: PLATFORM_ADMIN is stored as a user flag (isPlatformAdmin) and passes every check. New members get the default role (the platform’s, initially USER, or one their organization chose); an administrative role is never a default. Roles held by any member cannot be deleted.
  • Platform administration (platform admin): every organization in the organization screen (/organizations, under its Platform heading: create with an invited first admin, rename, suspend, reactivate, set a plan), /users (every account and its memberships, the platform admin flag, 2FA reset, soft delete), /admin/roles (the system roles and the platform default role), the helpdesk queue and the audit log across every organization.
  • Organization administration (by permission, in the active organization): members (change roles, remove, leave), invitations, units, the Users, Helpdesk and Audit log pages, and settings. A unit admin (UNIT_ADMIN, assigned in a unit) manages only that subtree, and sees the users, tickets and audit events of the people in it. See Organizations and units.
  • Invitations: an org admin (or unit admin, within their unit) invites an email with role assignments and a language; the platform admin does the same for a new organization’s first admin. The link is single-use and valid 7 days, can be resent or revoked, and re-inviting supersedes the earlier pending invitation to that organization. Accepting creates a verified account, or, for an email that already has an account, adds a membership to the logged-in user. See Invitations.
  • Client guards: the Angular client reads the caller’s effective permissions and guards routes (authGuard, platformAdminGuard).

Organizations and units

  • Multi-tenancy: one deployment, many organizations. A user has one login and any number of memberships; a member’s session has one active organization (in the access token) and a switcher in the header changes it. The platform admin (a platform flag on the user) has no active organization and works across all of them, with organization, unit and member filters on every list. Organizations are created by the platform admin or, when the product allows it, by any signed-in user, who becomes its admin.
  • Tenant scoping: a product model opts in with // @tenant (and // @owned for records owned by a user, // @unit for an optional unit). A Prisma extension scopes every query on it to the active organization (a platform admin’s to every organization, or the ones they name) and fails closed without one (403 ORGANIZATION_REQUIRED); inside a request a second one keeps each caller to their own records, and admins to the members and units in their reach, which anyone can narrow further with unit and member filter headers.
  • Units: a tree of branches or departments per organization, unit-scoped role assignments, and unit admins who manage only their subtree.
  • Modes: organizations: 'multi' | 'single' in the product config; a single-mode product has one built-in organization and hides the switcher.
  • Isolation tests: a route-keyed end-to-end matrix with a guard that fails when an organization-scoped route has no row, plus database integration specs. See Organizations and units.

Billing

  • Stripe subscriptions per organization: plans with limits (members, units, your own counters) and features, a free plan, a no-card trial, Stripe Checkout and the Customer Portal, webhooks applied idempotently. Optional: a product without billing in its config has no limits and needs no Stripe keys. See Billing.

Helpdesk

  • “Report a problem” from any page, with screenshots (local disk or S3-compatible storage), a staff queue for platform admins and for organization admins (the reporters in their reach), replies and status changes with notifications. See Helpdesk.

Notifications

  • In-app notifications with a bell and unread badge in the header and a /notifications page (mark read, read all, dismiss).
  • Delivered live over a server-sent event stream. The API uses Postgres LISTEN/NOTIFY, so every open tab and every API instance sees the event, with no Redis or other broker.
  • Notifications are written in the same transaction as the change that caused them (rolled back together). Thirteen types today: welcome, roles changed, invitation accepted, password changed, two-factor enabled, disabled or recovery code used, a sign-in method added or removed, and four helpdesk events (new ticket, replies both ways, status changed). Adding a type is documented in Notifications.

Background jobs

  • pg-boss runs on the same Postgres (own pgboss schema), so there is no extra infrastructure.
  • All outgoing mail goes through a mail.send queue. The job is inserted in the caller’s transaction (no mail without a commit, no commit without its mail), so requests never wait on SMTP and an SMTP outage delays mail rather than failing requests.
  • Job payloads are encrypted at rest (JOBS_ENCRYPTION_KEY), so links and tokens are not readable in the queue tables. Retries: 5 with exponential backoff; permanently failed jobs are kept 7 days in a dead-letter queue and logged. Mail templates exist in English and Greek (verification, password reset, invitation, 2FA notices). See Background jobs.

Profile and internationalization

  • Profile page: edit name, change password, theme and language preferences, delete the account (soft delete, audited), and manage two-factor authentication.
  • i18n: Transloco with English and Greek (en.json, el.json); a language switcher; the language preference is stored on the user and used for emails and invitations. API error codes are mapped to translated messages on the client. A translations test checks the two files stay in sync.

UI, design system and listing

  • Design system library: one library defines colour palette, semantic tokens (light and dark), fonts and typography. Light, dark and system theme with a toggle, applied before first paint; the choice is kept in local storage and in the profile preferences.
  • spartan/ui components (Helm layer over Brain primitives): alert, badge, button, card, dialog, dropdown menu, field, input, label, popover, progress, select, separator, sonner (toasts), switch, table, textarea, tooltip.
  • Shared list pattern: one query contract (search, sort, order, page, pageSize, response { data, total, page, pageSize }) used by users, invitations, roles, todos and audit events. A shared data-table component keeps list state in the URL and clamps stale or hand-edited links to valid values; a column can declare a filter by type (a choice list or a day range) that opens from its header, with one “Clear filters” button by the search; a sticky column (every actions column) stays pinned to the end edge while the rest of a wide table scrolls sideways; the API side has shared paging, LIKE-escaping and DTO helpers.
  • Layouts and pages: auth layout (login, register, check email, verify email, forgot/reset password, accept invitation) and a dashboard layout: a sidebar that collapses to icons (remembered per browser), with one Administration section holding every administrator’s pages - each shown to whoever holds its permission, scoped by the API from the caller’s place in the organization tree down (a unit admin their units, an organization admin the organization, a platform admin the platform) - and becomes a drawer on small screens, plus a header row with the page title (from the nav item or core page the URL sits under), the bell and the account menu; every page is lazy-loaded. Also a health page at /health (not linked from the sidebar).

Developer experience

  • Nx monorepo: apps/keelmint/api, apps/keelmint/client, apps/keelmint/api-e2e, and about 20 API and 20 client libraries, plus libs/core/shared (shared contracts, including the ApiError shape) and libs/core/db. Module boundaries are enforced by lint: API code cannot import client code, and feature libraries cannot import each other.
  • Typed contracts: DTO classes implement interfaces from @core/shared, so the Swagger/OpenAPI document (/api/docs, /api/docs-json) cannot drift from the shared types, and validation decorators stay out of the browser bundle.
  • Env validation: the API validates its environment at boot with zod and refuses to start, listing every problem at once and never printing secrets.
  • Structured logging: pino JSON lines (or pretty in development), request id from X-Request-Id or generated, returned in the response and attached to every log line and audit event.
  • One error format: every error is { statusCode, code, message, details?, requestId } with stable machine-readable codes (see Error codes); 5xx bodies never leak causes.
  • Database: Prisma migrations, keelmint-db:migrate, keelmint-db:seed (demo platform admin and users, refuses production and non-local databases), and a core-db:compose-schema step for the shared base fields.
  • Docker: docker-compose.yml for Postgres 17 and Mailpit (also creates the e2e test database); a multi-stage API Dockerfile on node:24-alpine that prunes to production dependencies, runs as an unprivileged user, and has a slim migrate stage.
  • Production deployment: per product, deploy/<product>/ runs Caddy (automatic HTTPS, the client, the /api proxy), the API, a one-off migration run and an optional Postgres with one docker compose up -d. A GitHub Actions release workflow pushes the API, migrate and web images of every product to GHCR on a version tag, and CI builds the images on every push to main and every pull request. Step by step, including backups and the first platform admin: Deploy.
  • Env configuration skill: configure-env (for Claude Code and other agents) asks for every variable one at a time, generates the secrets, writes a development or production .env and validates it with the API’s own schema (nx run core-api-env:check-env).
  • CI: GitHub Actions workflow with Postgres and Mailpit services, format check, then lint, test, build, typecheck and e2e through Nx (with Nx Cloud task distribution configured).
  • Health endpoint: GET /api/health runs a SELECT 1 probe and returns 503 when the database is down.

Testing

  • API end-to-end: about 600 tests in apps/keelmint/api-e2e against a real Postgres and Mailpit (with fake Google and Stripe servers), covering the full HTTP surface, plus a tenant-isolation matrix that fails when an organization-scoped route has no row.
  • Unit tests next to the code in every library, API and client.
  • Run everything with pnpm nx run-many -t test and e2e with pnpm nx e2e keelmint-api-e2e. See Testing.

What’s not included

  • SSO beyond Google: no SAML, other OAuth providers or passkeys/WebAuthn.
  • Per-seat or usage-based pricing: billing is a subscription per organization with count limits.
  • Session management UI: sessions are capped and revocable by the API, but users cannot list or revoke individual sessions in the UI.
  • Languages other than English and Greek. Adding one is a new JSON file and enum value (see Translations).
  • Larger hosting setups: one VPS with Docker Compose is included; Kubernetes/Terraform, several API replicas, zero-downtime rollouts and managed backups are not.
  • Email providers beyond SMTP.
  • Browser (UI) end-to-end tests: e2e covers the API; the client has unit and component tests.

Buyer checklist after purchase

  1. Generate your product (pnpm nx g @core/plugin:product <name>) and set its name, languages and options in its product.config.ts.
  2. Generate your own secrets: JWT_SECRET (32+ characters), JOBS_ENCRYPTION_KEY and TWO_FACTOR_ENCRYPTION_KEY (openssl rand -base64 32 each). Never reuse the CI/dev values, and keep them stable: changing the 2FA key invalidates enrolled authenticators and recovery codes.
  3. Configure real SMTP (SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, MAIL_FROM) and set APP_URL to the public client URL, since mailed links use it.
  4. Set CORS_ORIGIN if the client is not served from the same origin as the API, and review the refresh cookie’s SameSite=Lax assumption (Authentication).
  5. Point DATABASE_URL at your production Postgres; the deploy’s migrate service applies the migrations before the API starts (see Deploy). Never run the seed in production (it refuses to).
  6. Create your first platform admin (the seed’s demo accounts are for development only) and keep at least two so one can reset the other’s 2FA.
  7. Behind a proxy or load balancer, set TRUST_PROXY (a hop count or the proxies’ IPs) for correct audit IPs and rate limiting, and check the SSE stream passes through unbuffered.
  8. Swap the placeholder branding: design-system palette and fonts (fonts load from Google Fonts by default; self-host if needed), translations, email templates, and build your own product (see Your product) rather than editing the todos example. Decide organizations: 'multi' | 'single' before launch: multi to single is not supported.
  9. Run pnpm nx run-many -t lint test build typecheck e2e once to confirm a clean baseline, and connect Nx Cloud or remove it from the CI workflow.