Craftkitdocs

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_sessions for 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:

  1. User → Partner direct — rejected because users may run multiple product integrations (different brands, billing).
  2. Separate partners table not tied to project — rejected because templates would have ambiguous ownership.
  3. Project IS the partner — chosen. A project that has an embed_partners row 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 + version

How 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

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