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
-
Closure-capture `tenantId` in the Builder constructor — the engine never sees it
The engine takes
onBrowseandsaveUrlas constructor options. Both are CALLBACKS / strings that the engine invokes when a browse / save happens. The host writes the closure; the closure capturestenantIdfrom 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
tenantIdfield on the Builder instance, no constructor option calledtenant, notenant:activeevent on the bus. The engine's entire surface is tenant-naive.JavaScriptfunction 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. -
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 filteredThemeManifest[]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.phpreads tenantId from POST + uses this to build the destination key.saveDirFor($tenantId)— returns the directory key for this tenant's saved pages./backend/save.phpreads tenantId from query string + writes under the saves root + this dir.
Production buyers swap the hardcoded
TENANT_FIXTURESfor 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 */ } } -
Why the engine STAYS tenant-naive — and when to reconsider
Tempting alternative: bake
tenantIdinto the Builder constructor + readthis.tenantIdfrom internal call sites. We rejected it for three reasons documented in multi-tenant/SPEC.md §4:- 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.
- 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.
- 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])
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.