Appearance
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| Piece | Responsibility |
|---|---|
apps/web | Portals, operator UI, marketing, sessions, webhooks |
apps/worker | BullMQ when Redis is up; otherwise web runs jobs inline |
packages/db | Schema, SQL migrations, seed |
packages/domain | Pure product logic + RBAC |
packages/emails | Transactional templates |
Codebase to the live database
- Edit
packages/db/src/schema.ts. pnpm db:generatewrites SQL underpackages/db/drizzle/.pnpm db:migrateapplies it locally (DATABASE_URL, typicallyfile:./data/{app}.sqlite).- Push to
main: CI tests, lints, builds, then deploys. - The image runs
scripts/migrate-prod.mjsas release command and in the container entrypoint before the web server starts. - Production data is the volume file
file:/data/{app}.sqliteor 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
| Mode | Behaviour | When |
|---|---|---|
| A. Shared database | One SQLite or one Postgres, tenant_id everywhere | Default. Fastest clone. |
| B. Database per tenant | Control-plane DB plus tenants.database_url | Physical 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.
| Method | Notes |
|---|---|
| Email + password | bcrypt 12. Admin invite sets a temp password and emails it. |
| Forgot password | JWT, 1 hour. SSO-only accounts (no local hash) cannot reset here. |
| TOTP MFA | Required for privileged local-password roles. Secrets encrypted with SESSION_SECRET. SSO MFA stays at the IdP. |
| OIDC SSO | Per-tenant tenant_idp_configs. PKCE + state + nonce. Callback {APP_URL}/api/auth/oidc/callback. Flag sso. SAML optional, not required. |
| SCIM 2.0 | Machine identity: bearer token, not a browser session. |
| Platform operator | OIDC 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. |
| Redis | Optional REDIS_URL: shared rate limits + BullMQ worker. Not used for sessions. |
| Impersonation | Operator, 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_, flagscim - 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 URLIdempotency: 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.
Related
- Canonical spec:
docs/PLATFORM_FRAMEWORK.md - Architecture overview
- Provisioning tenants
- SSO setup
- Local setup
