Skip to content

Platform framework ​

Who this is for: Developers cloning this SaaS shape into a new product
What you'll achieve: A generic control plane: deploy, tenancy, auth (including SCIM), and checkout-to-tenant

Canonical copy for other repos: docs/PLATFORM_FRAMEWORK.md. Replace {app} and {domain} per product.

This is a modular monolith: one app process, many tenants. Default isolation is one shared database with tenant_id on every customer row. Database-per-customer is an optional adapter (Mode B), not the default.

Runtime ​

mermaid
flowchart TB
  Browser[Browser]
  Browser --> Web[apps/web Next.js]
  Web --> TRPC[tRPC]
  Web --> REST[REST: Stripe SCIM OIDC cron operator]
  TRPC --> Domain[packages/domain]
  TRPC --> DB[packages/db Drizzle]
  REST --> DB
  Worker[apps/worker optional] --> DB
  Cron[External scheduler] --> REST
PieceResponsibility
apps/webPortals, operator UI, marketing, sessions, webhooks
apps/workerBullMQ when Redis is up; otherwise web runs jobs inline
packages/dbSchema, SQL migrations, seed
packages/domainPure product logic + RBAC
packages/emailsTransactional templates

Codebase to the live database ​

  1. Edit packages/db/src/schema.ts.
  2. pnpm db:generate writes SQL under packages/db/drizzle/.
  3. pnpm db:migrate applies it locally (DATABASE_URL, typically file:./data/{app}.sqlite).
  4. Push to main: CI tests, lints, builds, then deploys.
  5. The image runs scripts/migrate-prod.mjs as release command and in the container entrypoint before the web server starts.
  6. Production data is the volume file file:/data/{app}.sqlite or a managed Postgres URL.

Seed is not run in production. Customers are created by createTenant (operator, CLI, or Stripe webhook).

If the migrator sees postgres://, it must apply Postgres migrations. Do not ship a stub that exits 0 without migrating.

Tenant queries without an obvious tenantId filter fail pnpm check:tenant-scope.

Tenancy modes ​

ModeBehaviourWhen
A. Shared databaseOne SQLite or one Postgres, tenant_id everywhereDefault. Fastest clone.
B. Database per tenantControl-plane DB plus tenants.database_urlPhysical isolation. Migrate that URL inside createTenant. Cache one client per tenant.

Tenant = contract, SSO/SCIM, Stripe, feature flags.
Site = optional operational unit (workspace, campus). A single customer still gets one default site.

Portals: /t/{subdomain}/… locally, or {subdomain}.{domain} when TENANT_URL_MODE=subdomain. Reserved names (www, operator, docs, signup, …) cannot be tenants.

Authentication ​

All tenant methods end in table sessions + httpOnly cookie {app}_session (14 days). Rename the cookie per product.

MethodNotes
Email + passwordbcrypt 12. Admin invite sets a temp password and emails it.
Forgot passwordJWT, 1 hour. SSO-only accounts (no local hash) cannot reset here.
TOTP MFARequired for privileged local-password roles. Secrets encrypted with SESSION_SECRET. SSO MFA stays at the IdP.
OIDC SSOPer-tenant tenant_idp_configs. PKCE + state + nonce. Callback {APP_URL}/api/auth/oidc/callback. Flag sso. SAML optional, not required.
SCIM 2.0Machine identity: bearer token, not a browser session.
Platform operatorOIDC SSO, email and password with mandatory TOTP, or a named hashed API token. DB sessions in operator_users / operator_sessions (8 hours, 30 minutes idle). Roles: platform_admin, support, readonly. SCIM provisioning at /api/operator/scim/v2. Sensitive actions need step-up: a TOTP check, or IdP re-authentication for SSO operators with no authenticator. OPERATOR_API_TOKEN bootstraps a new deployment and is then rejected. Missing every path closes /operator. OPERATOR_AUTH_DISABLED=1 is local only and is ignored when NODE_ENV=production.
RedisOptional REDIS_URL: shared rate limits + BullMQ worker. Not used for sessions.
ImpersonationOperator, 1 hour, audited.

After login, access state can block suspended, revoked, or trial expired tenants. Trial expiry is checked at login, tRPC, and SCIM. There is no expiry cron. trialEndsAt = null means no auto-expiry.

A suspended portal is recoverable and may be reopened by Stripe once payment succeeds. A revoked portal was closed by an operator under section 10 of the terms of service, and Stripe sync never reopens it. Both are audited with the operator identity and reason, and the school is emailed a notice.

Deleting a closed school's data is a separate, permission-gated step held until the published export window has run. It clears every table carrying a tenant_id, discovered from the schema so a new table cannot be missed, and keeps operator-written audit rows as the record that the closure and deletion happened when we say they did.

Emails are unique per tenant, not globally.

Authorization ​

Roles and permission strings live in packages/domain. tRPC wraps procedures with requirePermission("user.invite") (and similar). Feature flags on the tenant hide SSO, SCIM, Microsoft profile photos, and other capabilities.

Microsoft Graph integrations (Outlook calendar and profile photos) store Entra client secrets encrypted with INTEGRATION_ENCRYPTION_KEY. Photos are cached per person and served from /api/t/{subdomain}/avatars/{personId} to signed-in users only. See Microsoft profile photos.

SCIM ​

One standard SCIM 2.0 API for Entra, Okta, Google, or any SCIM client.

  • Base URL: https://{host}/api/scim/v2
  • Token: generated in Admin, hashed (SHA-256) in scim_tokens, prefix {app}scim_, flag scim
  • Users: match member by external id then email, else create; counts against seat limit
  • Groups: {App}-{Role} and optional {App}-{siteCode}-Staff
  • Rejected if tenant suspended, trial expired, or flag off

New tenant after subscription checkout ​

The signup form does not insert a tenant. Stripe Checkout does. The webhook provisions.

mermaid
sequenceDiagram
  participant User
  participant App
  participant Stripe
  participant DB
  User->>App: POST /api/billing/checkout
  App->>App: subdomain free? price id?
  App->>Stripe: Checkout Session subscription + trial
  Stripe->>User: hosted Checkout
  Stripe->>App: checkout.session.completed
  App->>DB: createTenant idempotent
  App->>User: welcome email + portal URL

Idempotency: if stripeSubscriptionId or subdomain already exists, the webhook returns that tenant and does not duplicate.

The same createTenant function is used for self-serve (webhook), the operator console, and pnpm tenant:create → POST /api/operator/tenants.

createTenant inserts tenant + sites + owner user + blank catalog. It never injects example customer data.

Later Stripe events (subscription.updated / deleted, invoice.payment_failed) sync plan, seats, active/suspended, and billing email. Sync leaves a revoked portal closed, and will not reopen a portal an operator suspended.

Checkout metadata should include subdomain, orgName, ownerEmail, seatLimit, plan, and optional sitesJson.

Deploy blueprint to reuse ​

  • Next.js output: "standalone" Docker image
  • Host region close to customers; volume or Postgres. Tenant row data_region (uk | us) is the residency pin for a later regional cluster.
  • Migrations on every deploy
  • CI: test, lint, build, e2e, then deploy
  • Docs site: separate workflow

Copy vs replace ​

Copy: tenants/users/sessions/operator_users/operator_sessions/idp/scim schema, createTenant, Stripe webhook, operator auth + SSO, OIDC, SCIM handler, tRPC permission wrappers, tenant host routing, migrate-prod, host/CI, tenant-scope lint.

Replace: domain engine, operational tables, marketing, glossary, RBAC names that are product-specific.

Upgrade when cloning: Mode B (database per tenant) if you need physical isolation.

SchoolRota documentation. Every slot covered, every day.