Tenancy model
Users, projects, partners, tenants, actors — who owns what.
06 — Tenancy Model
The tenancy model is the structural innovation that lets Craftkit be both a standalone SaaS for customers AND an embeddable platform for SaaS partners, without code branching or schema duplication.
The four-level hierarchy
┌─────────────────────────────────────────────────────────────┐
│ USER (Craftkit account) │
│ · Signed up at app.craftkit.dev │
│ · Pays Craftkit │
│ · Owns N projects │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ PROJECT (workspace / app) │
│ · Owns templates, API keys, webhooks, renders │
│ · MAY be marked as "embed partner" → unlocks embed mode │
│ ───────────────────────────────────────────────────── │
│ when in embed-partner mode, also owns: │
│ · publishable + secret + signing keys │
│ · allowed origins │
│ · permission presets │
│ · variable catalogs │
│ · tenants and actors (see below) │
└─────────────────────────────────────────────────────────────┘
│
─── if project is embed-partner mode ───
▼
┌─────────────────────────────────────────────────────────────┐
│ TENANT (partner's customer organization) │
│ · One tenant = one of the partner's end customers │
│ · Identified by partner-supplied external_id │
│ · Has its own templates (siloed from sibling tenants) │
│ · Drives per-tenant billing & limits │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ACTOR (end-user inside a tenant) │
│ · The human who opens the embed builder │
│ · Identified by partner-supplied external_id │
│ · Has UI preferences, hint dismissal state │
│ · Generates audit-trail attribution │
└─────────────────────────────────────────────────────────────┘Two modes, one schema
The same database serves two operating modes:
Mode 1 — Direct SaaS
A user signs up at app.craftkit.dev, creates a project, builds templates,
calls /v1/render from their backend. No tenants. No actors. No embed.
This is v0.1 — works today.
Mode 2 — Embed partner
A user signs up, creates a project, then enables embed mode on it. The
project gains a embed_partners row and unlocks:
- Publishable + secret + signing key trio
- Origin allowlist
- Permission presets
- Variable catalogs (versioned)
- The ability to mint
embed_sessionsfor tenants/actors
When their tenants edit templates inside the iframe, those templates are
owned by the project but attributed to a tenant via
template_external_links. Renders triggered for that tenant carry the
tenant_id in the renders row for billing & isolation.
Isolation guarantees
| Boundary | Enforced by |
|---|---|
| User can only see their own projects | where user_id = $1 on every query |
| Project A can't see Project B's data | where project_id = $1 on every query |
| Partner can't see another partner's tenants | where partner_id = $1 |
| Tenant A can't see Tenant B's templates | where tenant_id = $1 (in embed-attributed queries) |
| Iframe at session X can only access session X's catalog/template | JWT subject + claims, server-validated |
These are enforced at the data-access layer (in lib/projects.ts, lib/embed-sessions.ts), not as application-level filters in route handlers. This makes leakage almost impossible without a deliberate bypass.
Vocabulary discipline
The vocabulary is generic-by-construction:
partner— a Craftkit account holder running an embed integration. Never "tenant", never "customer."tenant— a partner's end customer (the org that pays them). Never "organization", never "team", never "workspace."actor— a human at the tenant who uses the embed builder. Never "user" (reserved for Craftkit account holders).
This discipline matters because some inspirational codebases use these words interchangeably — leading to ambiguous permission checks. We never do.
Why a project becomes a partner (rather than the user)
Considered alternatives:
- User → Partner direct — rejected because users may run multiple product integrations (different brands, billing).
- Separate
partnerstable not tied to project — rejected because templates would have ambiguous ownership. - Project IS the partner — chosen. A project that has an
embed_partnersrow attached is "in embed mode." Templates are owned by the project; tenants live underneath.
Lifecycle: from partner signup to first session
1. Founder signs up at app.craftkit.dev → user row
2. Creates project "kleesto-platform" → projects row
3. Settings → Embed → Enable → embed_partners row + 3 keys generated
4. Adds origins (https://app.kleesto.com, http://localhost:3000)
5. Defines a variable catalog (named, v1)
6. Defines permission presets (admin, editor, viewer)
7. Sets webhook URL for embed events
──── partner integrates Craftkit into their product ────
8. End user (kleesto's customer) clicks "Edit template" in kleesto UI
9. kleesto backend POSTs /v1/embed/sessions with secret key:
- tenant.external_id (their org id)
- actor.external_id (their user id)
- catalog_ref (or inline catalog)
- template.external_id (their template id, optional)
- permissions_preset (e.g. "editor")
10. Craftkit:
- upserts tenant by (partner_id, external_id)
- upserts actor by (partner_id, tenant_id, external_id)
- finds template by template_external_links OR creates a draft if mode=create
- mints JWT (5 min TTL) with claims
- returns { session_token, iframe_url, expires_at, renew_token }
11. kleesto frontend mounts iframe at /embed/builder?session_token=…
12. iframe validates JWT, fetches catalog, hydrates Tiptap editor
13. user edits → publishes
14. webhook fires to kleesto's backend with template + versionHow partners evolve their data model
A catalog is versioned, never mutated in place. When a partner adds a field, removes a field, or renames a key:
- They publish a new catalog version (v2, v3, …)
- Existing template chips reference the OLD version's keys
- On next session for that template, Craftkit:
- For renamed keys: offers one-click remap (writes a
key_alias) - For removed keys: shows missing-field UI; user can remap or remove
- For added keys: silently available in the picker
- For renamed keys: offers one-click remap (writes a
Catalogs are diffable in the admin UI to surface the impact of every change.
Self-hosted / on-prem
The same schema runs on-prem with no changes. The "Craftkit account holder" becomes the deploying organization; everything else is identical. This means partner code paths, embed JWTs, and observability all just work in self-hosted deploys.
Last revised: 2026-05-02