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
| Layer | Choice | Version (from package.json) |
|---|---|---|
| Monorepo | Nx, pnpm | Nx 23.1.1, pnpm 11.18.0 |
| Runtime | Node.js | 24 (Dockerfile and CI) |
| Language | TypeScript | 6.0.3 |
| API | NestJS (Express), Passport JWT, Swagger | NestJS 11 |
| API build | tsdown (oxc/rolldown), single main.cjs bundle | 0.22 |
| Database | PostgreSQL with Prisma (@prisma/adapter-pg) | Prisma 7.9.1, Postgres 17 (Docker/CI) |
| Jobs | pg-boss on the same Postgres | 12 |
| nodemailer over SMTP, Mailpit for development | nodemailer 10 | |
| Validation | class-validator on the API, zod for env | zod 4 |
| Client | Angular (standalone, signals) | 22.0 |
| UI | spartan/ui (Brain + Helm), Tailwind CSS, lucide icons | spartan 1.5, Tailwind 4 |
| i18n | Transloco | 8 |
| Logging | pino / nestjs-pino | pino 10 |
| Tests | Jest, jest-preset-angular | Jest 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 anHttpOnly,SameSite=Lax,Path=/api/authcookie (Securein 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_VERIFIEDbefore 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_ORIGINis set, a globalValidationPipewithwhitelistandforbidNonWhitelisted. - Soft delete: users, roles, organizations, memberships, units, todos, invitations and helpdesk tickets are never hard-deleted. A Prisma extension adds the
isDeletedfilter and turns plaindeleteon those models into an error; usesoftDelete/softDeleteMany. Shared fields (id, timestamps,isDeleted) come from onebase.prismafile composed into the schema. - Audit log: an append-only
audit_eventstable 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) orGET /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 returns403 PERMISSION_REQUIREDwith the permission name indetails. - Platform admin and default role:
PLATFORM_ADMINis stored as a user flag (isPlatformAdmin) and passes every check. New members get the default role (the platform’s, initiallyUSER, 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// @ownedfor records owned by a user,// @unitfor 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 (403ORGANIZATION_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
billingin 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
/notificationspage (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
pgbossschema), so there is no extra infrastructure. - All outgoing mail goes through a
mail.sendqueue. 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; astickycolumn (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, pluslibs/core/shared(shared contracts, including theApiErrorshape) andlibs/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
implementinterfaces 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-Idor 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 acore-db:compose-schemastep for the shared base fields. - Docker:
docker-compose.ymlfor Postgres 17 and Mailpit (also creates the e2e test database); a multi-stage API Dockerfile onnode:24-alpinethat prunes to production dependencies, runs as an unprivileged user, and has a slimmigratestage. - Production deployment: per product,
deploy/<product>/runs Caddy (automatic HTTPS, the client, the/apiproxy), the API, a one-off migration run and an optional Postgres with onedocker 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.envand 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/healthruns aSELECT 1probe and returns 503 when the database is down.
Testing
- API end-to-end: about 600 tests in
apps/keelmint/api-e2eagainst 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 testand e2e withpnpm 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
- Generate your product (
pnpm nx g @core/plugin:product <name>) and set its name, languages and options in itsproduct.config.ts. - Generate your own secrets:
JWT_SECRET(32+ characters),JOBS_ENCRYPTION_KEYandTWO_FACTOR_ENCRYPTION_KEY(openssl rand -base64 32each). Never reuse the CI/dev values, and keep them stable: changing the 2FA key invalidates enrolled authenticators and recovery codes. - Configure real SMTP (
SMTP_HOST,SMTP_PORT,SMTP_SECURE,SMTP_USER,SMTP_PASS,MAIL_FROM) and setAPP_URLto the public client URL, since mailed links use it. - Set
CORS_ORIGINif the client is not served from the same origin as the API, and review the refresh cookie’sSameSite=Laxassumption (Authentication). - Point
DATABASE_URLat your production Postgres; the deploy’smigrateservice applies the migrations before the API starts (see Deploy). Never run the seed in production (it refuses to). - 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.
- 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. - 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. - Run
pnpm nx run-many -t lint test build typecheck e2eonce to confirm a clean baseline, and connect Nx Cloud or remove it from the CI workflow.