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), verified signup and in-product billing, docs, and marketing
Canonical copy for other repos: docs/PLATFORM_FRAMEWORK.md. Replace {app} and {domain} per product. That file is the pack to copy; this page is the short map.
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 implemented in the default control plane.
Runtime
mermaid
flowchart TB
Browser[Browser]
Browser --> Marketing[Marketing and signup]
Browser --> Portal[Tenant host]
Browser --> Operator[Operator console]
Browser --> Docs[docs host]
Marketing --> Web[apps/web Next.js]
Portal --> Web
Operator --> Web
Docs --> Web
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 | Marketing, portals, operator UI, sessions, webhooks, embedded docs |
apps/worker | BullMQ when Redis is up; otherwise web runs jobs inline. Default host runs web only |
packages/db | Schema, SQL migrations, seed |
packages/domain | Pure product logic + RBAC |
packages/emails | Transactional templates |
docs-site/ | VitePress. GitHub Pages plus copy into apps/web/public/__docs |
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 (including tenant-scope lint), lints, builds, runs e2e, 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).
SQLite is the default production path (one Fly machine, one volume). 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 (also run from pnpm test).
Tenancy modes
| Mode | Behaviour | When |
|---|---|---|
| A. Shared database | One SQLite or one Postgres, tenant_id everywhere | Default. Implemented. No per-tenant deploy |
| B. Database per tenant | Control-plane DB plus tenants.database_url | Physical isolation. Clone upgrade. 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 subdomain mode is on. Reserved names (www, app, api, operator, docs, signup, login, …) cannot be tenants.
After signup the app does not create DNS or a new Fly app. *.{domain} already points at the same process. Checkout inserts a tenants row; the Host header selects it.
Public hosts (same process)
| Host | Surface |
|---|---|
Apex / www | Marketing ((marketing) route group), including /signup and /login (school finder; redirects to the tenant host) |
{subdomain}.{domain} | Tenant portal (middleware rewrite to /t/{subdomain}) |
/operator | Operator console |
docs.{domain} | VitePress rewrite to /__docs |
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. Constant-time compare. 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. Client secrets encrypted with INTEGRATION_ENCRYPTION_KEY. 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 (including operator.tenants.lifecycle and operator.tenants.purge for admins). 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 until a named human (SSO or local admin) and a named API token exist. OPERATOR_BOOTSTRAP_OVERRIDE=1 is emergency-only. Optional OPERATOR_IP_ALLOWLIST on Fly. 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. |
Operator holds block access. Expired trials allow authorised reads and exports while shared server capabilities block writes, including SCIM and background jobs. Trial lifecycle and billing reconciliation run on scheduled jobs.
A suspended portal is recoverable and may be reopened by Stripe once payment succeeds. A revoked portal was closed by an operator under the termination clause of the terms, and Stripe sync never reopens it. Both are audited with the operator identity and reason.
Deleting a closed tenant'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.
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, integrations, and other capabilities.
Integration client secrets (calendar, directory photos, OIDC) are encrypted with INTEGRATION_ENCRYPTION_KEY. Cached photos, if used, are served from authenticated tenant routes only.
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 - Writes rejected when the tenant lacks write entitlement; reads retain their normal role and feature checks
Verified no-card signup and later subscription
Public signup requires a school or trust email. Submission creates a pending signup and reserves the portal address. Email verification followed by password creation provisions the school once, starts its immutable 30-day trial, and hands off to a tenant-host session. Opening a verification link alone does not activate it.
The owner continues later in Admin → Billing. Card checkout is the usual route. During the trial, card setup arranges a subscription with the original expiry as the first charge date. After expiry, subscription checkout charges immediately on confirmation. Schools that cannot use a card can request a purchase order from the same page. Webhooks and periodic reconciliation verify payment evidence; checkout never provisions a tenant.
Expired trials retain authorised reads and exports, account security, support and billing. Product writes and background distribution stop. Operator suspension and revocation remain separate from payment restrictions.
The shared createTenant service also serves operator provisioning. It seeds a blank school, not example customer data. Public activation uses single-use verification, transactional portal claims and a password chosen by the owner.
To copy this offer into Inclusion Loop or another app, use docs/NO_CARD_TRIAL_PLAYBOOK.md and the short map in No-card trial playbook.
Docs and marketing
- Docs: VitePress in
docs-site/. Docs CI publishes GitHub Pages. Webbuildembeds the same dist intopublic/__docs. Middleware rewrites the docs host. - Marketing: App Router
(marketing)inapps/web. Cookie consent gates GA4. Measurement IDs come from env, not hardcoded fallbacks.
Deploy blueprint to reuse
- Next.js
output: "standalone"Docker image - Host region close to customers; volume or Postgres. Tenant row
data_regionis a residency pin for a later regional cluster, not a second database today - Migrations on every deploy
- CI: test, lint, build, e2e, then deploy
- Docs site: separate workflow plus embed in the app image
- Wildcard DNS for tenant hosts; one Fly app
Copy vs replace
Copy: tenants/users/sessions/operator schema, createTenant, Stripe webhook, operator auth + SSO, OIDC, SCIM handler, tRPC permission wrappers, tenant host routing, docs embed, marketing route group, migrate-prod, host/CI, tenant-scope lint.
Replace: domain engine, operational tables, marketing copy, glossary, RBAC names that are product-specific, docs title and host names.
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

