Skip to content
Last updated Give Feedback

Tenants

The Tenants panel (/tenants in super-admin) is where the platform operator provisions new customer stores, suspends ones in arrears, restores after payment, and — rarely — deletes.

Access requires the tenant_provisioning license feature (Enterprise tier and above).

A tenant is one row in the tenants table + every record that filters by its tenantId in middleware. Tenants are isolated at the DB query level — every apps/api route that touches data filters by req.tenantId set in the resolveTenant middleware. See Architecture for the full isolation model.

A tenant has:

  • A unique slug (acme) that becomes the subdomain (acme.mystore.cloud) under SaaS hosting, or the host header (X-Ecommus-Tenant) under B2B integrations
  • A primary niche (one of services / real-estate / travel / ecommerce)
  • A theme assignment (one of the niche’s licensed themes)
  • A set of enabled plugins (subset of the customer’s licensed plugins)
  • A status: active / suspended / archived
  • A subscription pointer (which plan + when it renews)
[create]
active ──────► suspended ──────► active (recoverable)
│ │
↓ ↓
archived ◄──── archived (terminal — data retained per GDPR window)

POST /api/super-admin/tenants — invoked from /tenants/new:

{
"slug": "acme", // unique, lowercase, [a-z0-9-]
"name": "Acme Wellness",
"niche": "services",
"theme": "theme-services",
"plan_id": "<uuid>", // from /plans
"owner_email": "ana@acme.ro", // initial admin user
"send_welcome_email": true
}

Server-side flow:

  1. Validate slug uniqueness + niche/theme/plan licence compatibility
  2. Insert tenants row with status='active'
  3. Run seed-tenant.ts — creates a default storefront homepage, a settings row with sane defaults, the tenant-side admin user (random password, email-reset link)
  4. Subscribe to license-server for the renewal cycle (Stripe customer + subscription created)
  5. Send welcome email to owner_email with login link + temp password
  6. Audit log: tenant.create

The first request to the new subdomain takes ~5 s longer than subsequent ones — the resolveTenant cache is cold.

POST /api/super-admin/tenants/:id/suspend — invoked from the tenant detail view.

When a tenant is suspended:

  • All /api/storefront/* routes return 503 tenant_suspended for that host
  • Tenant admin login fails with 403 tenant_suspended
  • Background workers skip jobs where tenant_id = <suspended>
  • The license server still considers the install part of the customer’s count for billing — suspension is a hold, not a refund

Common reasons: payment failure, T&C violation, customer request for a planned outage.

POST /api/super-admin/tenants/:id/restore — flips status back to active. Workers resume on the next tick. Storefront unblocks immediately (no cache invalidation needed — resolveTenant re-reads on every request when the tenant is suspended-flagged).

POST /api/super-admin/tenants/:id/archive — moves to archived. Different from suspend:

  • Customer data is retained per the GDPR retention window (default 30 days, configurable in /settings)
  • The slug is released — another tenant can claim it
  • Storefront returns 410 gone instead of 503
  • Subscription is cancelled at the license-server (no further charges)
  • After the retention window, a cron drops the data permanently

Archive is the customer-facing “delete account” path.

/tenants/:id shows:

  • Status + recent transitions (audit-log scoped to this tenant)
  • Plan + renewal date + payment status (active / past_due / canceled)
  • Theme + enabled plugins
  • Counts — products, orders (last 30 d), customers, monthly revenue
  • Owner — email, last login, 2FA status
  • License — pointer to the JWT issued for this tenant (links to /licenses/:id)
  • Custom domain — if white_label feature is on, the operator can map mystore.ro → this tenant
  • Quick actions — suspend, restore, archive, “log in as tenant” (creates a short-lived impersonation token + audit-logs it)

The most powerful action in the panel. Creates a 5-minute impersonation token, logs the operator into the tenant’s apps/admin as a synthetic admin user whose actions are flagged impersonated_by=<operator-id> in every write.

When NOT to use:

  • Customer hasn’t asked for support. The audit log makes the access visible — they will see it.
  • Modifying customer data without their explicit permission. Read-only support is fine; mutation requires a ticket reference in the impersonation modal.

The impersonation event is also surfaced in the tenant’s own admin audit log — they see it from their side too.

  • Slug collisions across archive→create: if acme is archived and someone tries to create a new acme within the retention window, the create fails with 409 slug_in_retention. Force release with POST /api/super-admin/tenants/:id/release-slug (audit-logged).
  • Cross-tenant query bugs: any new admin route that misses WHERE tenantId = req.tenantId is a CLAUDE.md §4 violation. The tenant-isolation-checker agent runs on every PR to catch this. Don’t disable it.
  • Subdomain mode + custom domains: when a tenant has a white_label custom domain, resolveTenant matches by Host header first; the subdomain still works as a fallback. The custom domain TLS cert is provisioned via Caddy / Let’s Encrypt on the platform-side reverse proxy.
  • Plan downgrade: moving a tenant from a higher plan to a lower one may strip plugins. The UI flags this before the change; operators see the diff and confirm explicitly. Stripped plugins keep running for the 30-day grace window then deactivate.