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), 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
PieceResponsibility
apps/webMarketing, portals, operator UI, sessions, webhooks, embedded docs
apps/workerBullMQ when Redis is up; otherwise web runs jobs inline. Default host runs web only
packages/dbSchema, SQL migrations, seed
packages/domainPure product logic + RBAC
packages/emailsTransactional templates
docs-site/VitePress. GitHub Pages plus copy into apps/web/public/__docs

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 (including tenant-scope lint), lints, builds, runs e2e, 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).

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 ​

ModeBehaviourWhen
A. Shared databaseOne SQLite or one Postgres, tenant_id everywhereDefault. Implemented. No per-tenant deploy
B. Database per tenantControl-plane DB plus tenants.database_urlPhysical 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) ​

HostSurface
Apex / wwwMarketing ((marketing) route group), including /signup and /login (school finder; redirects to the tenant host)
{subdomain}.{domain}Tenant portal (middleware rewrite to /t/{subdomain})
/operatorOperator 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.

MethodNotes
Email + passwordbcrypt 12. Constant-time compare. 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. Client secrets encrypted with INTEGRATION_ENCRYPTION_KEY. 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 (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.
RedisOptional REDIS_URL: shared rate limits + BullMQ worker. Not used for sessions.
ImpersonationOperator, 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_, flag scim
  • 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. Web build embeds the same dist into public/__docs. Middleware rewrites the docs host.
  • Marketing: App Router (marketing) in apps/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_region is 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.

SchoolRota documentation. Every slot covered, every day.