feat(multi-tenancy): make the core Hub/admin console work in single-tenant deployments#175
Merged
Merged
Conversation
…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>
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary — one story, two layers
The Hub operator console (
/hub/*,/admin/*) is unusable in a single-tenantdeployment (
FEATURE_MULTI_TENANCY_ENABLED=false) for two independentreasons — 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 readsthe active tenant an interceptor places in the AsyncLocalStorage. The core
TenantInterceptoris the only thing that populates that ALS — and it isregistered only when multi-tenancy is enabled:
A single-tenant deployment runs
FEATURE_MULTI_TENANCY_ENABLED=false, soTenantInterceptoris never mounted and nothing sets the ALS tenant forHub/admin routes →
requireTenantContext()throwsBadRequestException("tenant context is required")→ 400.GET /admin/roles400s and the Roles adminpage 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 evenwith the server fixed, the pages stay in "Loading …" because their react-query
enabled: tenantId.length > 0gate never flips on.Note: the correct single-tenant tenant is not
SINGLE_TENANT_ID. Anoperator's roles live under their organization's
tenant_id(theirmemberrow), which need not equal
SINGLE_TENANT_ID; a blind fallback would resolve thewrong tenant and find zero roles. The right value is the operator's own
membership tenant — what
resolveHubOperatorTenantId(user, prisma)alreadycomputes.
Fix
Server —
HubOperatorTenantInterceptor(src/core/hub/hub-operator-tenant.interceptor.ts, new).Registered on the opposite side of the
multiTenancyflag inapp.module.ts, soexactly one of the two interceptors is ever active. For
isHubPortalProtectedPathrequests it resolves the operator's own membershiptenant via
resolveHubOperatorTenantIdand runs the handler insiderunWithTenant()(streamToPromise pattern, mirroringTenantInterceptor, andrunWithTenantimported frommulti-tenancy/tenant-context.jsto avoid thedocumented
PrismaService-before-init cycle). Every other path — the wholeproduct
/api/*surface — passes through untouched.When the tenant is unresolvable (no
req.user/ no membership) the interceptorpasses through instead of throwing: tenant-required handlers still 400 via
their own
requireTenantContext(), while tenant-optional@Publicprobes(
/hub/portal-access.json, and the new/hub/operator-tenant.json) keep workingfor membership-less operators. No blind
SINGLE_TENANT_IDfallback.Server —
GET /hub/operator-tenant.jsonprobe (HubController,@Public+assertDev(), mirrorsportalAccessJson). Returns{ tenantId: string | null }via
resolveHubOperatorTenantId(req.user, prisma), so the client can learn itsserver-resolved tenant.
Client — bootstrap fallback (
src/core/dx/clients/lib/hub-session-bootstrap.ts).bootstrapHubOperatorSession()now falls back to/hub/operator-tenant.jsonwhen/api/auth/organization/list404s or returns an empty list, and returns itstenantId. The tenant-gated pages then receive a real tenant and render exactlyas in multi-tenant mode — no page change needed. The multi-tenant bootstrap path
(list → pick default →
set-active→ return org id) is unchanged.Isolation
resolveHubOperatorTenantIdis reused unchanged and preserves every invariant:member.findFirst({ where: { userId } }); a foreignorg's role is absent from
GET /admin/roles(proven in the test).SINGLE_TENANT_ID— a user with no membership resolves tonull;the tenant-required handler still 400s.
isHubPortalProtectedPathis wrapped;/api/*stayspass-through (no new 400 on the product surface).
!features.multiTenancy.enabled;multi-tenant deployments still run only
TenantInterceptor, verbatim.Tests
tests/hub-operator-tenant-single.e2e-spec.ts(new) — boots in realsingle-tenant mode (
FEATURE_MULTI_TENANCY_ENABLED="false"pinned before adynamic
import()ofbootstrap), org/member/roles seeded via Prisma (noset-active), real Better-Auth session. 4 passed: member operator →/admin/roles200 with own role; foreign role absent; membership-less → 400 on/admin/rolesbut 200 on the@Public/hub/portal-access.jsonprobe.tests/stories/hub-session-bootstrap.story.test.ts— 7 passed (multi-tenantpath 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 allgreen.
OpenAPI
Adds one path to
docs/openapi.snapshot.json— the new/hub/operator-tenant.jsonroute (regenerated via
dump:openapi). No change to any/api/*route, so thegenerated SDK surface is unaffected.