Skip to content

Repository files navigation

system-management-edc

A Turborepo monorepo with a NestJS backend authenticated by Better Auth, backed by Drizzle ORM + PostgreSQL.

Architecture

Authentication is split into two shared packages consumed by the NestJS app, so the DB connection and the auth instance each have a single source of truth:

apps/
  backend/        NestJS 11 (Express). Mounts Better Auth via
  │               @thallesp/nestjs-better-auth → exposes /api/auth/*
  │                 ▲ depends on
  └── web/         TanStack Start app (auth client wiring is a follow-up)

packages/
  auth/  (@repo/auth)   betterAuth({ ... }) instance + inferred Session/User types
  │        ▲ depends on
  db/    (@repo/db)     Drizzle client (pg Pool) + generated auth schema + migrations
  ui/    eslint-config/ typescript-config/   (shared tooling)
  • @repo/db owns the PostgreSQL connection (db) and the Drizzle schema. The auth tables (user, session, account, verification) are generated by the Better Auth CLI into packages/db/src/schema/auth.ts.
  • @repo/auth builds the betterAuth instance on top of @repo/db using drizzleAdapter(db, { provider: "pg", schema }), and exports the inferred Session / User types. Email & password auth is enabled with public sign-up disabled (disableSignUp: true), LDAP/AD login is provided by better-auth-credentials-plugin (see LDAP / Active Directory login), and all cookies use the prefix sme-bismillah (e.g. sme-bismillah.session_token).
  • apps/backend registers AuthModule.forRoot({ auth }); the library mounts all /api/auth/* routes and installs a global AuthGuard.

@repo/db and @repo/auth are compiled packages (tsc → dist/). Turbo's build.dependsOn: ["^build"] builds them before the backend, whose nest build (plain tsc, no bundler) consumes their emitted JS + declarations.

Setup

Requires Node ≥ 18 (Node ≥ 22 recommended — the auth package is ESM-only and relies on native require(esm)), pnpm 9, and a PostgreSQL database.

pnpm install

# 1. Create the database (example uses a db named `sme_db`)
createdb -U postgres sme_db            # or: CREATE DATABASE sme_db;

# 2. Configure env (see "Environment variables" below)
cp .env.example apps/backend/.env      # runtime
cp .env.example packages/db/.env       # drizzle-kit CLI
#   → set DATABASE_URL, e.g. postgres://postgres:<password>@localhost:5432/sme_db
#   → set BETTER_AUTH_SECRET (generate one, see below)

# 3. Create the auth tables
pnpm --filter @repo/db db:migrate

# 4. Run the backend
pnpm --filter backend start:dev        # http://localhost:3001

Generate a secret for BETTER_AUTH_SECRET with the Better Auth CLI:

pnpm --filter @repo/db exec better-auth secret

Environment variables

Validated with zod at import time (packages/db/src/env.ts, packages/auth/src/env.ts):

Variable Used by Description
DATABASE_URL @repo/db PostgreSQL connection string
BETTER_AUTH_SECRET @repo/auth Secret used to sign session cookies (≥ 16 chars; ≥ 32 recommended)
BETTER_AUTH_URL @repo/auth Base URL the auth server runs at, e.g. http://localhost:3001
TRUSTED_ORIGINS @repo/auth, CORS Comma-separated origins allowed to send credentialed requests
PORT apps/backend Backend HTTP port (default 3001)
LDAP_URL @repo/auth LDAP server URL (default: public Forum Systems test server)
LDAP_BIND_DN @repo/auth Service account DN used to search for users
LDAP_BIND_PASSWORD @repo/auth Password for the bind DN
LDAP_SEARCH_BASE @repo/auth Base DN searched for user entries
LDAP_USERNAME_ATTRIBUTE @repo/auth Attribute matched against the email's local part (default uid; use sAMAccountName for AD)
LDAP_EMAIL_DOMAINS @repo/auth Comma-separated email domains that authenticate via LDAP

All LDAP_* variables default to the free public Forum Systems LDAP test server, so LDAP login works out of the box in development.

Auth endpoints

Mounted by @thallesp/nestjs-better-auth under /api/auth:

Method Endpoint Purpose
POST /api/auth/sign-up/email Disabled400 EMAIL_PASSWORD_SIGN_UP_DISABLED
POST /api/auth/sign-in/email Sign in with email & password; sets the session cookie
POST /api/auth/sign-in/credentials Sign in via LDAP/AD (domains in LDAP_EMAIL_DOMAINS only)
POST /api/auth/sign-out Clear the session (requires an Origin header)
GET /api/auth/get-session Current session (or null)

Example sign-in:

curl -X POST http://localhost:3001/api/auth/sign-in/email \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"a-strong-password"}'

Mobile Update API

Public endpoint — no authentication required.

Method Endpoint Purpose
GET /mobile/version Hybrid OTA / APK update check for mobile clients

Query parameters

Parameter Required Example Description
currentVersion No 1.0.0 Installed app version. Omit for legacy clients
platform No android Target platform (default: android)
runtimeVersion No 1.0.0 Expo runtime version of the client app
channel No production OTA release channel (production, preview, staging)

Response shape

The backend compares currentVersion against minimumVersion and latestVersion in the database, then returns one of three updateType values:

  • ota — Expo OTA update available; includes channel and runtimeVersion.
  • apk — Standalone APK update required (used when below minimumVersion or when updateType=apk is stored); includes downloadUrl, checksum, fileSize.
  • none — App is already on the latest or newer version.
# Client with version check
curl "http://localhost:3001/mobile/version?currentVersion=1.0.0&platform=android&channel=production"

# Legacy client (backward compatible — no currentVersion)
curl "http://localhost:3001/mobile/version"

Example OTA response:

{
  "updateAvailable": true,
  "updateType": "ota",
  "forceUpdate": false,
  "minimumVersion": "1.0.0",
  "latestVersion": "1.0.1",
  "releaseNotes": "Bug fixes and performance enhancements",
  "channel": "production",
  "runtimeVersion": "1.0.0",
  "downloadUrl": "https://example.com/downloads/app-release.apk",
  "updateUrl": "https://example.com/downloads/app-release.apk",
  "checksum": "e3b0c44...",
  "fileSize": 15420000,
  "publishedAt": "2026-08-03T10:00:00.000Z",
  "isActive": true
}

Example APK / force-update response:

{
  "updateAvailable": true,
  "updateType": "apk",
  "forceUpdate": true,
  "minimumVersion": "1.0.0",
  "latestVersion": "2.0.0",
  "releaseNotes": "Major release — APK update required",
  "version": "2.0.0",
  "downloadUrl": "https://example.com/downloads/app-v2.apk",
  "checksum": "sha256-hash",
  "fileSize": 20000000,
  "publishedAt": "2026-08-03T10:00:00.000Z",
  "isActive": true
}

Environment variables for mobile updates

Variable Description Default
MOBILE_OTA_CHANNEL Default Expo OTA channel when not provided by client production
MOBILE_OTA_RUNTIME_VERSION Default Expo runtime version when not stored in DB 1.0.0
MOBILE_APK_DOWNLOAD_URL Fallback APK download URL when not stored in DB (empty)

Creating users

Public self-service registration is disabled, so the sign-up/email endpoint is closed. Create users through the server-side auth API instead — from a script or an admin-only route using the internal adapter:

import { auth } from '@repo/auth';

const ctx = await auth.$context;
const password = await ctx.password.hash('a-strong-password');
const user = await ctx.internalAdapter.createUser({
  email: 'admin@example.com',
  name: 'Admin',
  emailVerified: true,
});
await ctx.internalAdapter.linkAccount({
  userId: user.id,
  providerId: 'credential',
  accountId: user.id,
  password,
});

Sample users (development)

Accounts available for local development and testing the login page:

Email Password Flow
admin@example.com a-strong-password Email & password (providerId: credential)
einstein@ldap.forumsys.com password LDAP (providerId: ldap)

admin@example.com was created manually with the script above. The LDAP user is auto-provisioned on first login against the public Forum Systems test server — other test users (newton, tesla, gauss, euler, …, all with password password) work the same way. The web login form routes by email domain (VITE_LDAP_EMAIL_DOMAINS), so both flows go through the same form.

LDAP / Active Directory login

POST /api/auth/sign-in/credentials (added by better-auth-credentials-plugin) authenticates against an LDAP directory instead of the local password table. The flow, implemented in packages/auth/src/ldap.ts + packages/auth/src/auth.ts:

  1. The email's domain must be listed in LDAP_EMAIL_DOMAINS, otherwise the request is rejected — regular users keep using sign-in/email.
  2. The email's local part is matched against LDAP_USERNAME_ATTRIBUTE (einstein@ldap.forumsys.com → search uid=einstein), then the entry is bound with the submitted password.
  3. On first successful login the user is auto-provisioned (autoSignUp) with emailVerified: true; the account row uses providerId: "ldap", separate from the "credential" password flow. No extra tables are needed, so packages/db/better-auth.config.ts intentionally omits the plugin.

With the default env values you can try it against the public Forum Systems test server — users einstein, newton, tesla, gauss, euler, … all with password password:

curl -X POST http://localhost:3001/api/auth/sign-in/credentials \
  -H "Content-Type: application/json" \
  -d '{"email":"einstein@ldap.forumsys.com","password":"password"}'

A frontend can route by domain using the exported helper:

import { isLdapEmail } from '@repo/auth';
// authClient must include the plugin:
// createAuthClient({ plugins: [credentialsClient()] })
// with credentialsClient from "better-auth-credentials-plugin/client"

if (isLdapEmail(email)) {
  await authClient.signIn.credentials({ email, password }); // LDAP/AD
} else {
  await authClient.signIn.email({ email, password });       // local password
}

Production: point the LDAP_* vars at your own directory — use ldaps://host:636, set LDAP_USERNAME_ATTRIBUTE=sAMAccountName (or userPrincipalName) for Active Directory, and restrict LDAP_EMAIL_DOMAINS to your company domain. The Forum Systems server is a public read-only test box; never leave the defaults enabled in production.

Protecting routes

A global AuthGuard is registered by AuthModule.forRoot, so every route is protected by default and returns 401 without a valid session cookie.

import { Controller, Get } from '@nestjs/common';
import { Public, Session, type UserSession } from '@thallesp/nestjs-better-auth';

@Controller()
export class AppController {
  // Opt a route out of the global guard:
  @Public()
  @Get()
  getHello(): string {
    return 'Hello World!';
  }

  // Protected — 401 without a session cookie. Read the current user:
  @Get('me')
  getMe(@Session() session: UserSession) {
    return session.user;
  }
}

For server-side auth API calls, inject AuthService<typeof auth> and pass request headers via fromNodeHeaders(req.headers).

Note: apps/backend/src/main.ts creates the app with { bodyParser: false } so Better Auth can read raw request bodies; the library re-adds the default parsers for all other routes. CORS is enabled with credentials: true and TRUSTED_ORIGINS. Session cookies are httpOnly + sameSite=lax, prefixed with sme-bismillah (and __Secure- in production when NODE_ENV=production).

Regenerating the schema

When you change auth options/plugins, keep packages/db/better-auth.config.ts in sync with packages/auth/src/auth.ts, then:

pnpm --filter @repo/db auth:generate   # npx @better-auth/cli → src/schema/auth.ts
pnpm --filter @repo/db db:generate     # drizzle-kit → SQL migration in drizzle/
pnpm --filter @repo/db db:migrate      # apply to the database

db:studio opens Drizzle Studio for the configured database.

Common commands

pnpm turbo build          # build all packages/apps
pnpm turbo lint           # lint
pnpm turbo check-types    # typecheck packages
pnpm --filter backend test

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages