Skip to content

feat(multi-tenancy): make the core Hub/admin console work in single-tenant deployments#175

Merged
pascal-klesse merged 1 commit into
mainfrom
feat/single-tenant-hub-console
Jul 16, 2026
Merged

feat(multi-tenancy): make the core Hub/admin console work in single-tenant deployments#175
pascal-klesse merged 1 commit into
mainfrom
feat/single-tenant-hub-console

Conversation

@pascal-klesse

Copy link
Copy Markdown
Member

Summary — one story, two layers

The Hub operator console (/hub/*, /admin/*) is unusable in a single-tenant
deployment (FEATURE_MULTI_TENANCY_ENABLED=false) for two independent
reasons — a server gap and a client gap. Both are fixed here as one coherent
change.

Problem

Core Hub/admin routes serve tenant-scoped handlers that call
requireTenantContext() (multi-tenancy/require-tenant-context.ts), which reads
the active tenant an interceptor places in the AsyncLocalStorage. The core
TenantInterceptor is the only thing that populates that ALS — and it is
registered only when multi-tenancy is enabled:

// src/core/app/app.module.ts
...(features.multiTenancy.enabled
  ? [{ provide: APP_INTERCEPTOR, useClass: TenantInterceptor }]
  : []),

A single-tenant deployment runs FEATURE_MULTI_TENANCY_ENABLED=false, so
TenantInterceptor is never mounted and nothing sets the ALS tenant for
Hub/admin routes → requireTenantContext() throws BadRequestException("tenant context is required")400. GET /admin/roles 400s and the Roles admin
page hangs at "Loading roles…". Every tenant-scoped Hub/admin route shares this
root cause when multi-tenancy is off.

Separately, the dev-portal SPA gates its tenant-scoped admin pages (roles,
permissions, tenants) on an active organization it learns from
/api/auth/organization/list, which 404s when the org plugin is off — so even
with the server fixed, the pages stay in "Loading …" because their react-query
enabled: tenantId.length > 0 gate never flips on.

Note: the correct single-tenant tenant is not SINGLE_TENANT_ID. An
operator's roles live under their organization's tenant_id (their member
row), which need not equal SINGLE_TENANT_ID; a blind fallback would resolve the
wrong tenant and find zero roles. The right value is the operator's own
membership tenant — what resolveHubOperatorTenantId(user, prisma) already
computes.

Fix

Server — HubOperatorTenantInterceptor (src/core/hub/hub-operator-tenant.interceptor.ts, new).
Registered on the opposite side of the multiTenancy flag in app.module.ts, so
exactly one of the two interceptors is ever active. For
isHubPortalProtectedPath requests it resolves the operator's own membership
tenant via resolveHubOperatorTenantId and runs the handler inside
runWithTenant() (streamToPromise pattern, mirroring TenantInterceptor, and
runWithTenant imported from multi-tenancy/tenant-context.js to avoid the
documented PrismaService-before-init cycle). Every other path — the whole
product /api/* surface — passes through untouched.

When the tenant is unresolvable (no req.user / no membership) the interceptor
passes through instead of throwing: tenant-required handlers still 400 via
their own requireTenantContext(), while tenant-optional @Public probes
(/hub/portal-access.json, and the new /hub/operator-tenant.json) keep working
for membership-less operators. No blind SINGLE_TENANT_ID fallback.

Server — GET /hub/operator-tenant.json probe (HubController, @Public +
assertDev(), mirrors portalAccessJson). Returns { tenantId: string | null }
via resolveHubOperatorTenantId(req.user, prisma), so the client can learn its
server-resolved tenant.

Client — bootstrap fallback (src/core/dx/clients/lib/hub-session-bootstrap.ts).
bootstrapHubOperatorSession() now falls back to /hub/operator-tenant.json when
/api/auth/organization/list 404s or returns an empty list, and returns its
tenantId. The tenant-gated pages then receive a real tenant and render exactly
as in multi-tenant mode — no page change needed. The multi-tenant bootstrap path
(list → pick default → set-active → return org id) is unchanged.

Isolation

resolveHubOperatorTenantId is reused unchanged and preserves every invariant:

  1. Own membership onlymember.findFirst({ where: { userId } }); a foreign
    org's role is absent from GET /admin/roles (proven in the test).
  2. No blind SINGLE_TENANT_ID — a user with no membership resolves to null;
    the tenant-required handler still 400s.
  3. Scope — only isHubPortalProtectedPath is wrapped; /api/* stays
    pass-through (no new 400 on the product surface).
  4. Multi-tenant path untouched — gated on !features.multiTenancy.enabled;
    multi-tenant deployments still run only TenantInterceptor, verbatim.

Tests

  • tests/hub-operator-tenant-single.e2e-spec.ts (new) — boots in real
    single-tenant mode (FEATURE_MULTI_TENANCY_ENABLED="false" pinned before a
    dynamic import() of bootstrap), org/member/roles seeded via Prisma (no
    set-active), real Better-Auth session. 4 passed: member operator →
    /admin/roles 200 with own role; foreign role absent; membership-less → 400 on
    /admin/roles but 200 on the @Public /hub/portal-access.json probe.
  • tests/stories/hub-session-bootstrap.story.test.ts7 passed (multi-tenant
    path unchanged; single-tenant 404/empty → operator-tenant probe; tenantId:null
    / probe failure → undefined, no throw).

Verified on a clean checkout of this repo: bun install, prepare:schema,
prisma:generate, lint, test:types, dump:openapi, and both test files all
green.

OpenAPI

Adds one path to docs/openapi.snapshot.json — the new /hub/operator-tenant.json
route (regenerated via dump:openapi). No change to any /api/* route, so the
generated SDK surface is unaffected.

…enant deployments

The Hub operator console (/hub/*, /admin/*) was unusable when multi-tenancy is
disabled (FEATURE_MULTI_TENANCY_ENABLED=false) for two independent reasons — a
server gap and a client gap — fixed here as one coherent change.

Server: TenantInterceptor is registered only when multiTenancy is enabled
(app.module.ts), so single-tenant deployments have no provider for the ALS
tenant that the core Hub/admin handlers require via requireTenantContext() →
400 "tenant context is required" (the Roles page hangs at "Loading roles…").
New HubOperatorTenantInterceptor, mounted on the opposite side of the flag
(mutually exclusive with TenantInterceptor), resolves the operator's OWN
membership tenant via resolveHubOperatorTenantId for isHubPortalProtectedPath
requests and runs the handler inside runWithTenant(); every other path — the
whole product /api/* surface, which pins its own tenant — passes through
untouched. When the tenant is unresolvable (no session / no membership) it
passes through WITHOUT a tenant rather than throwing, so tenant-required
handlers still 400 via their own requireTenantContext() while tenant-optional
@public probes (/hub/portal-access.json) keep working for membership-less
operators. No blind SINGLE_TENANT_ID fallback — an operator's roles live under
their membership org's tenant_id, which need not equal SINGLE_TENANT_ID.

New GET /hub/operator-tenant.json probe (@public + assertDev, mirrors
portalAccessJson) returns { tenantId } via resolveHubOperatorTenantId so the
client can learn its server-resolved tenant.

Client: the dev-portal SPA gated its tenant-scoped admin pages on an active org
from /api/auth/organization/list, which 404s when the org plugin is off, leaving
tenantId empty and the query disabled (perpetual "Loading …").
bootstrapHubOperatorSession now falls back to /hub/operator-tenant.json on a
404/empty org list; the pages receive a real tenant and render unchanged. The
multi-tenant bootstrap path (list → pick → set-active) is unchanged.

Tests: hub-operator-tenant-single.e2e-spec.ts boots in real single-tenant mode
(4 passed) — roles resolve for the operator's membership (was 400), no
foreign-tenant leak, membership-less → 400 on /admin/roles but 200 on the
portal-access probe. hub-session-bootstrap.story.test.ts covers the fallback
branch (7 passed). OpenAPI snapshot regenerated for the one new /hub/* route;
no /api SDK change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@pascal-klesse
pascal-klesse merged commit 94aa8dd into main Jul 16, 2026
13 checks passed
@pascal-klesse
pascal-klesse deleted the feat/single-tenant-hub-console branch July 16, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant