Extensions · 5 of 5

Multi-tenant SaaS — closure-captured tenantId per Builder

Two side-by-side editors share ONE engine bundle but are fully isolated by tenant. Asset browses + saves route through tenant-scoped URLs via closure-captured `tenantId` in the constructor; the engine itself stays tenant-naive (zero `src/` change). Backend `_lib/TenantRegistry.php` answers "what is this tenant allowed to access?" — listForTenant / assetRootFor / saveDirFor.

Walkthrough

  1. Closure-capture `tenantId` in the Builder constructor — the engine never sees it

    The engine takes onBrowse and saveUrl as constructor options. Both are CALLBACKS / strings that the engine invokes when a browse / save happens. The host writes the closure; the closure captures tenantId from the surrounding scope. Per Builder instance, the tenantId is BAKED in for the lifetime of the editor.

    Two editors with two tenantIds = two closures = two different upload/save URLs. The engine bundle (dist/builder.js, dist/builder.css) is loaded ONCE; the tenant-aware behaviour lives entirely in the host's constructor calls. Multi-tenant is a HOST concern, not an engine concern.

    This is what the SPEC means by "Builder.js core stays tenant-unaware." There is no tenantId field on the Builder instance, no constructor option called tenant, no tenant:active event on the bus. The engine's entire surface is tenant-naive.

    JavaScript
    function buildEditorFor(tenantId) {
      // tenantId is closure-captured; the engine never sees it.
      return new Builder({
        mainContainer:     '#' + tenantId + '-canvas',
        widgetsContainer:  '#' + tenantId + '-widgets',
        settingsContainer: '#' + tenantId + '-settings',
    
        // Asset browse — host opens its picker. Closure captures tenantId
        // and attaches it via extraFields so the upload POST carries
        // tenant context server-side.
        onBrowse: function (handleUrl) {
          DemoFileBrowser.pickFile(
            function (r) { handleUrl(r.url); },
            {
              endpoint:    '/backend/asset-upload.php',
              extraFields: { tenantId: tenantId },   // ← closure-captured
            }
          );
        },
    
        // Save URL — closure-captured tenantId in the query string.
        // /backend/save.php reads tenantId server-side + routes to
        // TenantRegistry::saveDirFor(tenantId).
        saveUrl: '/backend/save.php?tenantId=' + encodeURIComponent(tenantId),
      });
    }
    
    const editorA = buildEditorFor('acme-corp');
    const editorB = buildEditorFor('globex-llc');
    
    // editorA.saveUrl  → '/backend/save.php?tenantId=acme-corp'
    // editorB.saveUrl  → '/backend/save.php?tenantId=globex-llc'
    // editorA's onBrowse will POST tenantId=acme-corp; editorB → globex-llc.
    // One engine bundle. Two complete tenant isolations.
  2. Backend registry — `_lib/TenantRegistry.php` answers "what is this tenant allowed to access?"

    Three concerns, three methods. The class doesn't authenticate (that's _lib/Auth.php's job in the demo backend); it assumes a validated tenant ID and answers what they're ALLOWED to access:

    • listForTenant($tenantId) — returns the filtered ThemeManifest[] this tenant can use. The demo ships three fixtures: Acme + Stark see all 63 themes; Globex sees only the 5 basic-email samples.
    • assetRootFor($tenantId) — returns the absolute filesystem path / S3 prefix where this tenant's uploads land. /backend/asset-upload.php reads tenantId from POST + uses this to build the destination key.
    • saveDirFor($tenantId) — returns the directory key for this tenant's saved pages. /backend/save.php reads tenantId from query string + writes under the saves root + this dir.

    Production buyers swap the hardcoded TENANT_FIXTURES for a database query (SELECT theme_dir FROM tenant_themes WHERE tenant_id = ?), an LDAP lookup, or a feature-flag service call. Cache layer (Redis with 5-minute TTL) and telemetry (`error_log("tenant.theme_list tenant=$tid count=$n")`) are typical extensions.

    PHP
    // Single-file class; demo/backend/_lib/TenantRegistry.php.
    final class TenantRegistry
    {
        private const TENANT_FIXTURES = [
            'acme-corp' => [
                'displayName' => 'Acme Corp',
                'themeDirs'   => ['*'],   // all themes
                'sampleAllowList' => null,
                'assetRoot'   => '/tmp/builderjs-demo/tenants/acme-corp',
                'saveDir'     => 'acme-corp',
            ],
            'globex-llc' => [
                'displayName' => 'Globex LLC',
                'themeDirs'   => ['default'],
                'sampleAllowList' => [
                    'master/sample/email/Blank',
                    'master/sample/email/Minimal',
                    'master/sample/email/SimpleText',
                    'master/sample/email/Gallery',
                    'master/sample/email/SellProducts',
                ],
                'assetRoot'   => '/tmp/builderjs-demo/tenants/globex-llc',
                'saveDir'     => 'globex-llc',
            ],
            // ...
        ];
    
        public function listForTenant(string $tenantId): array { /* filter */ }
        public function assetRootFor(string $tenantId): string { /* lookup */ }
        public function saveDirFor(string $tenantId): string  { /* lookup */ }
    }
  3. Why the engine STAYS tenant-naive — and when to reconsider

    Tempting alternative: bake tenantId into the Builder constructor + read this.tenantId from internal call sites. We rejected it for three reasons documented in multi-tenant/SPEC.md §4:

    1. Engine ownership creep. Tenant identity is a host concern. The engine doesn't know what a tenant IS or how to authenticate one. Pushing tenant-awareness into the engine means the engine takes positions on tenant SCOPE / LIFECYCLE / AUTH — none of which it has business answering.
    2. Bundle size for tenant-naive apps. Most BuilderJS deployments are single-tenant. Adding tenant hooks to the engine means even tenant-naive apps ship the hook code. Closure capture in HOST code keeps the engine bundle the same regardless.
    3. Closure capture works. The pattern in Step 1 gives complete isolation — events / pinSet / recent-colors / active-controls are all per-Builder-instance (W6.A.A.4 multi-instance refactor proved this). Adding engine-side hooks would solve a problem we don't have.

    When to reconsider: if a real SaaS use-case surfaces that closure capture CAN'T solve (e.g., a tenant-scoped engine lifecycle hook that needs to be observable across instances + cleaned up on tenant archive), file a SPEC.md amendment with a worked example showing why closure capture fails. Until then, tenant-naive engine + tenant-aware host stays canonical.

    PHP
    // Backend wiring — /backend/save.php (sketch).
    //
    // The engine POSTs to whatever URL the buyer-app supplied at construct
    // time. The buyer-app's PHP handler reads tenantId from the query string
    // (or POST), validates it, and routes to the registry.
    
    require_once __DIR__ . '/_lib/Validator.php';
    require_once __DIR__ . '/_lib/Auth.php';
    require_once __DIR__ . '/_lib/ThemeRegistry.php';
    require_once __DIR__ . '/_lib/TenantRegistry.php';
    
    use DemoBuilder\Validator;
    use DemoBuilder\Auth;
    use DemoBuilder\ThemeRegistry;
    use DemoBuilder\TenantRegistry;
    
    Auth::requireSession();   // host's auth path — re-uses session validator.
    
    $tenantId = Validator::make($_GET)
        ->required('tenantId')
        ->regex('/^[A-Za-z0-9_-]+$/')
        ->validateOrFail()['tenantId'];
    
    // Defense-in-depth — re-validate that this tenantId is one this user
    // has permission for. The host's auth layer answers this; we just call.
    Auth::requireTenantAccess($tenantId);
    
    $themes   = new ThemeRegistry(__DIR__ . '/../themes');
    $registry = new TenantRegistry($themes);
    $saveDir  = $registry->saveDirFor($tenantId);
    
    // Now write the page payload under $savesRoot/$saveDir/$pageId.json.
    // (Validator already vetted the JSON shape; Storage handles the IO.)
    $savesRoot = __DIR__ . '/../var/saves';
    if (!is_dir($savesRoot . '/' . $saveDir)) {
        mkdir($savesRoot . '/' . $saveDir, 0755, true);
    }
    // ...write the file, return JsonResponse::ok(['ok' => true])

Live demo

demo-mini-builder--multi-tenant
Click a Probe button to inspect the closure-captured URL
tenant
tenant

Notes

The probe buttons surface the closure-captured URLs. Click "Probe Acme browse URL" → status pill shows what onBrowse WOULD POST through DemoFileBrowser: POST /backend/asset-upload.php body: tenantId=acme-corp + file=<binary>. Click "Probe Globex" — same endpoint URL but tenantId=globex-llc. Same pattern for save: Acme posts to /backend/save.php?tenantId=acme-corp; Globex to /backend/save.php?tenantId=globex-llc. Different tenant, different URL — driven entirely by HOST closure capture; the engine bundle is identical.

Why two PROBE buttons instead of triggering real browses? Two reasons. (1) Real browses need a real file from the buyer\'s disk; the demo can\'t synthesize that hermeticly. (2) The educational point is the URL DIFFERENCE — buyers learn faster from "click button → see the URL" than from "configure something → trace through DevTools." The probe buttons read builder.options.saveUrl + the closure-captured tenantId so what you see is exactly what the engine would send.

Theme list filtering — `listForTenant` returns the SUBSET each tenant can use. The cookbook fixtures: Acme sees ALL 63 themes (acme-corp\'s `themeDirs: ['*']`); Globex sees only 5 basic email templates (globex-llc\'s `sampleAllowList: ['Blank', 'Minimal', 'SimpleText', 'Gallery', 'SellProducts']`); Stark Industries sees all 63 + a custom theme. In production this comes from your tenant store (database / LDAP / feature-flag service) — the cookbook hardcodes for clarity. The DEMO doesn\'t hand each Builder a different theme catalog (both editors load the same default theme bundle for visual demonstration), but the SPEC + walkthrough show the canonical filtered-list pattern. scripts/demo/screenshot-multi-tenant.cjs covers the visual scoping for non-interactive proof.

Test surface: the spec asserts (1) two Builders mount independently into the per-tenant containers; (2) the per-instance saveUrls differ (`acme-corp` vs `globex-llc`); (3) probe buttons surface the closure-captured URLs distinctly; (4) the closure-captured tenantId roundtrips through `onBrowse` (a synthesized fetch + url+body assertion). The engine is NOT tested here — it doesn\'t know about tenants, so there\'s nothing engine-side to test. Closure-capture isolation is enforced by code review of `init.js` not granting any hooks beyond what the demo registers.

This closes Phase D 3/3. SPEC at docs/plans/multi-tenant/SPEC.md (W6.D.1) · `_lib/TenantRegistry.php` at demo/backend/_lib/TenantRegistry.php (W6.D.3) · this example (W6.D.4). After Phase D, Phase F.B story-driven layers (6 items) + F.C 6-round audit + close-out (9 items) + Phase E close-out (6 items) close W6. Then W7 Gallery opens.