diff --git a/docs/openapi.snapshot.json b/docs/openapi.snapshot.json index 0180c1c8..5e7bbc7f 100644 --- a/docs/openapi.snapshot.json +++ b/docs/openapi.snapshot.json @@ -487,6 +487,20 @@ ] } }, + "/hub/operator-tenant.json": { + "get": { + "operationId": "Hub_operatorTenantJson", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Hub" + ] + } + }, "/hub/dashboard.json": { "get": { "operationId": "Hub_dashboardJson", diff --git a/src/core/app/app.module.ts b/src/core/app/app.module.ts index e0e38afe..f5864f47 100644 --- a/src/core/app/app.module.ts +++ b/src/core/app/app.module.ts @@ -20,6 +20,7 @@ import { DeviceModule } from "../devices/device.module.js"; import { HubSpaModule } from "../dx/hub-spa.module.js"; import { UserAdminModule } from "../dx/user-admin.module.js"; import { HubModule } from "../hub/hub.module.js"; +import { HubOperatorTenantInterceptor } from "../hub/hub-operator-tenant.interceptor.js"; import { HubPortalMiddleware } from "../hub/hub-portal.middleware.js"; import { EmailModule } from "../email/email.module.js"; import { EmailOutboxModule } from "../email/email-outbox.module.js"; @@ -249,6 +250,15 @@ const features = loadFeatures(process.env as Record) ...(features.multiTenancy.enabled ? [{ provide: APP_INTERCEPTOR, useClass: TenantInterceptor }] : []), + // Single-tenant deployments never mount `TenantInterceptor` above, so + // core Hub/admin routes (`requireTenantContext()`) would have no tenant + // in the ALS → 400. This Hub-scoped interceptor resolves the operator's + // OWN membership tenant for `/hub/*` + `/admin/*` only; all other paths + // (the product `/api/*` surface, which pins its own tenant) pass through + // untouched. Mutually exclusive with `TenantInterceptor` by the flag. + ...(!features.multiTenancy.enabled + ? [{ provide: APP_INTERCEPTOR, useClass: HubOperatorTenantInterceptor }] + : []), ], }) export class AppModule implements NestModule { diff --git a/src/core/dx/clients/lib/hub-session-bootstrap.ts b/src/core/dx/clients/lib/hub-session-bootstrap.ts index b326ec17..f482a317 100644 --- a/src/core/dx/clients/lib/hub-session-bootstrap.ts +++ b/src/core/dx/clients/lib/hub-session-bootstrap.ts @@ -18,19 +18,31 @@ export function pickDefaultOrganizationId( } /** - * After Hub sign-in, activate a default Better-Auth organization via set-active. - * Tenant scope for hub/admin JSON comes from the session cookie only. + * After Hub sign-in, resolve the tenant the operator's admin pages scope to. + * + * MULTI-TENANT: `/api/auth/organization/list` returns the operator's orgs; we + * pick a default, `set-active` it (tenant scope then comes from the session + * cookie), and return its id. This path is unchanged. + * + * SINGLE-TENANT: the org plugin is off, so `organization/list` 404s (or + * returns an empty list). There is no `activeOrganizationId` to seed the + * tenant-gated admin pages, which would otherwise keep their query disabled + * and hang in "Loading …". We fall back to `/hub/operator-tenant.json`, which + * resolves the operator's OWN membership tenant server-side, and return that + * id so the pages render exactly as in multi-tenant mode. */ export async function bootstrapHubOperatorSession(): Promise { const listRes = await fetch("/api/auth/organization/list", { credentials: "include", headers: { accept: "application/json" }, }); - if (!listRes.ok) return undefined; + // 404 → single-tenant (org plugin off): resolve the tenant server-side. + if (!listRes.ok) return resolveSingleTenantOperatorTenantId(); const orgs = (await listRes.json()) as HubOrganizationSummary[]; const organizationId = pickDefaultOrganizationId(orgs); - if (!organizationId) return undefined; + // Empty list → still single-tenant-shaped: same server-side fallback. + if (!organizationId) return resolveSingleTenantOperatorTenantId(); await fetch("/api/auth/organization/set-active", { method: "POST", @@ -45,6 +57,23 @@ export async function bootstrapHubOperatorSession(): Promise return organizationId; } +/** + * Single-tenant tenant resolution: ask the server which tenant the operator's + * membership maps to. No `set-active` here — the org plugin does not exist in + * single-tenant mode; the `HubOperatorTenantInterceptor` stamps the tenant per + * request from the same membership resolver. Returns `undefined` when the + * probe fails or the operator has no membership (server sends `tenantId: null`). + */ +async function resolveSingleTenantOperatorTenantId(): Promise { + const res = await fetch("/hub/operator-tenant.json", { + credentials: "include", + headers: { accept: "application/json" }, + }); + if (!res.ok) return undefined; + const body = (await res.json()) as { tenantId: string | null }; + return body.tenantId ?? undefined; +} + /** Switch the Better-Auth session to a specific organization (hub/admin JSON). */ export async function activateHubOrganization(organizationId: string): Promise { const res = await fetch("/api/auth/organization/set-active", { diff --git a/src/core/dx/hub.controller.ts b/src/core/dx/hub.controller.ts index 0397a57b..b5f78fdc 100644 --- a/src/core/dx/hub.controller.ts +++ b/src/core/dx/hub.controller.ts @@ -121,6 +121,7 @@ import { ApiTags } from "@nestjs/swagger"; import type { Ability } from "../permissions/casl-ability.js"; import { buildHubPortalAccessSnapshot } from "../hub/hub-portal-access.js"; +import { resolveHubOperatorTenantId, type HubOperatorUser } from "../hub/hub-operator-tenant.js"; import { buildHubNavFeatureSnapshot, filterPalettePagesForNavSnapshot } from "./hub-nav-planner.js"; import { Public } from "../permissions/public.decorator.js"; import { assertFeatureEnabledFromEnv } from "../features/assert-feature-enabled.js"; @@ -229,6 +230,41 @@ export class HubController { return buildHubPortalAccessSnapshot(req.ability, buildHubNavFeatureSnapshot(features)); } + /** + * `/hub/operator-tenant.json` — the tenant the signed-in operator's Hub + * pages should scope to. Single-tenant deployments run the org plugin off, + * so `/api/auth/organization/list` 404s and the dev-portal SPA has no + * `activeOrganizationId` to seed its tenant-gated admin pages (roles, + * policies, tenants). This probe lets the client learn its server-resolved + * tenant: the operator's OWN membership org via `resolveHubOperatorTenantId` + * (same resolver the single-tenant `HubOperatorTenantInterceptor` uses). + * + * `@Public` + `assertDev()` mirror `portalAccessJson`: it must be reachable + * WITHOUT a tenant context (it is what RESOLVES the tenant). The + * `HubOperatorTenantInterceptor` passes tenant-less hub requests through, so + * no interceptor 400 blocks this. Returns `{ tenantId: null }` for an + * unauthenticated caller or an operator with no membership — no blind + * `SINGLE_TENANT_ID` fallback (isolation: only the caller's own membership). + * + * Upstream candidate (hub.controller.ts — see docs/upstream-drafts/README.md + * "Hub tenant-scoped admin routes (roles)"): pairs with the interceptor + + * client bootstrap fix so the core Hub admin console works in single-tenant + * deployments end-to-end. + */ + @Get("operator-tenant.json") + @Public( + "client resolves its single-tenant hub scope — reachable before any tenant context exists", + ) + async operatorTenantJson( + @Req() req: { user?: HubOperatorUser }, + ): Promise<{ tenantId: string | null }> { + this.assertDev(); + if (!req.user) { + return { tenantId: null }; + } + return { tenantId: await resolveHubOperatorTenantId(req.user, this.prisma) }; + } + @Get("dashboard.json") async dashboardJson(): Promise { this.assertDev(); diff --git a/src/core/hub/hub-operator-tenant.interceptor.ts b/src/core/hub/hub-operator-tenant.interceptor.ts new file mode 100644 index 00000000..4c7a65fb --- /dev/null +++ b/src/core/hub/hub-operator-tenant.interceptor.ts @@ -0,0 +1,143 @@ +import { + Injectable, + Optional, + type CallHandler, + type ExecutionContext, + type NestInterceptor, +} from "@nestjs/common"; +import type { Request } from "express"; +import { Observable, defer, from, switchMap } from "rxjs"; + +import { runWithTenant } from "../multi-tenancy/tenant-context.js"; +import { isTenantExempt } from "../multi-tenancy/tenant-guard.js"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { resolveHubOperatorTenantId } from "./hub-operator-tenant.js"; +import { isHubPortalProtectedPath } from "./hub-portal-paths.js"; + +/** + * Hub-scoped tenant interceptor for SINGLE-TENANT deployments. + * + * Upstream candidate (hub-operator-tenant.interceptor.ts — see + * docs/upstream-drafts/README.md "Hub tenant-scoped admin routes (roles)"): + * the core `TenantInterceptor` is gated on `features.multiTenancy.enabled` + * (app.module.ts:255), so in a single-tenant deployment + * (`FEATURE_MULTI_TENANCY_ENABLED=false`) it is never mounted. The core + * Hub/admin routes still call `requireTenantContext()`, so with no + * interceptor in the chain they have no tenant in the ALS and throw 400 + * "tenant context is required" — the Roles page hangs in "Loading roles…". + * + * This interceptor fills exactly that gap and NOTHING else: + * + * - Only the Hub operator console (`isHubPortalProtectedPath`) needs a + * tenant in single-tenant mode. Those requests resolve the operator's + * OWN membership tenant via `resolveHubOperatorTenantId` and run the + * handler inside `runWithTenant()` so the Prisma RLS extension stamps + * `SET app.tenant_id` and `requireTenantContext()` reads a value. + * - EVERY other path is pure pass-through. The product `/api/*` surface + * pins its own tenant (session `activeOrganizationId` / SINGLE_TENANT_ID + * resolvers) and MUST keep running exactly as today — this interceptor + * never makes a non-Hub path tenant-required. + * + * When the tenant is NOT resolvable (no `req.user`, no Prisma, or the + * operator has no membership) the interceptor PASSES THROUGH instead of + * throwing. `isHubPortalProtectedPath` also matches tenant-OPTIONAL hub + * probes — notably the `@Public` `/hub/portal-access.json` access probe, + * which decides hub access and MUST answer without a tenant (a + * membership-less operator gets a "no access" snapshot, not a 400). + * Throwing here would break those probes. Tenant-REQUIRED handlers + * (`GET /admin/roles`, …) call `requireTenantContext()` themselves, so + * with no tenant in the ALS they still throw their own 400 — the + * "membership-less → 400 on /admin/roles" behaviour is preserved, just + * enforced by the handler rather than the interceptor. + * + * Isolation invariant: the fallback resolves ONLY the caller's own + * membership — there is no blind `SINGLE_TENANT_ID` fallback, so no + * foreign-tenant leak (an authenticated user with no membership never + * inherits another tenant's context). This interceptor and the core + * `TenantInterceptor` are mutually exclusive: they are wired on opposite + * sides of the `multiTenancy` flag, so never both active. + * + * `runWithTenant` is imported from `tenant-context.ts` (never from + * `tenant.interceptor.ts`) to avoid the `PrismaService`-before-init cycle + * documented there. + */ + +interface HubAuthenticatedRequest extends Request { + user?: { + id: string; + activeOrganizationId?: string | null; + }; +} + +@Injectable() +export class HubOperatorTenantInterceptor implements NestInterceptor { + /** + * `@Optional()` mirrors `TenantInterceptor`: synthetic test modules may + * wire the interceptor without importing `PrismaModule`. Production wiring + * imports `PrismaModule` (it is `@Global`), so the real path always has a + * Prisma instance. Without Prisma the membership cannot be resolved, so + * the request passes through WITHOUT a tenant — tenant-required handlers + * then throw their own `requireTenantContext()` 400. + */ + constructor(@Optional() private readonly prisma?: PrismaService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== "http") { + return next.handle(); + } + const req = context.switchToHttp().getRequest(); + const path = (req.originalUrl ?? req.url ?? "/") as string; + + // Public/system paths (/, /health/*, /api/auth/*) — never tenant-scoped. + if (isTenantExempt(path)) { + return next.handle(); + } + + const purePath = stripQuery(path); + // Non-Hub paths (the whole product `/api/*` surface) pin their own tenant + // and must pass through untouched in single-tenant mode. + if (!isHubPortalProtectedPath(purePath)) { + return next.handle(); + } + + return defer(async () => { + const tenantId = + req.user && this.prisma ? await resolveHubOperatorTenantId(req.user, this.prisma) : null; + if (!tenantId) { + // Tenant not resolvable (no session / no Prisma / no membership). + // Pass through WITHOUT a tenant instead of throwing — no blind + // SINGLE_TENANT_ID fallback (that would leak a foreign tenant). + // Tenant-OPTIONAL hub probes (the @Public /hub/portal-access.json + // access probe) must answer without a tenant; tenant-REQUIRED + // handlers (/admin/roles, …) throw their own requireTenantContext() + // 400, so the membership-less → 400 contract is preserved there. + return streamToPromise(next.handle()); + } + return runWithTenant(tenantId, () => streamToPromise(next.handle())); + }).pipe(switchMap((value) => from(unwrap(value)))); + } +} + +function streamToPromise(observable: Observable): Promise { + return new Promise((resolveResult, rejectResult) => { + let last: unknown; + observable.subscribe({ + next: (v) => { + last = v; + }, + error: rejectResult, + complete: () => resolveResult(last), + }); + }); +} + +function unwrap(value: unknown): Promise { + return Promise.resolve(value); +} + +function stripQuery(path: string): string { + const queryAt = path.indexOf("?"); + const hashAt = path.indexOf("#"); + const cut = Math.min(...[queryAt, hashAt].filter((i) => i >= 0), path.length); + return path.slice(0, cut); +} diff --git a/tests/hub-operator-tenant-single.e2e-spec.ts b/tests/hub-operator-tenant-single.e2e-spec.ts new file mode 100644 index 00000000..b39ab85c --- /dev/null +++ b/tests/hub-operator-tenant-single.e2e-spec.ts @@ -0,0 +1,159 @@ +import type { INestApplication } from "@nestjs/common"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { PrismaService } from "../src/core/prisma/prisma.service.js"; +import { + createApiTestSession, + ensureOrganizationMember, + type ApiTestSession, +} from "./helpers/api-request.js"; + +const SILENT_LOGGER = { log() {}, warn() {}, error() {}, debug() {}, verbose() {} }; + +/** + * Single-tenant Hub regression (the real BYND deployment shape). + * + * BYND runs `FEATURE_MULTI_TENANCY_ENABLED=false`, so the core + * `TenantInterceptor` is NOT mounted (app.module.ts:255 gates it on + * `features.multiTenancy.enabled`). The tenant-scoped admin JSON routes + * (`GET /admin/roles`, …) still call `requireTenantContext()` — with no + * interceptor there is no tenant in the ALS, so they threw 400 "tenant + * context is required" and the Roles page hung in "Loading roles…". + * + * The fix mounts a Hub-scoped single-tenant interceptor + * (`HubOperatorTenantInterceptor`) that resolves the operator's OWN + * membership tenant for `isHubPortalProtectedPath` requests. Product + * `/api/*` paths stay pass-through (they pin their own tenant). + * + * Isolation invariant proven here: the fallback resolves ONLY the + * caller's own membership — no foreign-tenant leak, no blind + * SINGLE_TENANT_ID (a user with no membership still gets 400). + * + * `FEATURE_MULTI_TENANCY_ENABLED` is read at import time by + * `loadFeatures()` in app.module.ts, so it is pinned BEFORE the dynamic + * `import()` of `bootstrap` (a top-level static import would evaluate the + * app-module graph before `beforeAll` runs). + */ +describe("Hub · single-tenant operator tenant resolution for admin JSON", () => { + let app: INestApplication; + let prisma: PrismaService; + + // Per-suite unique tenants keep this spec isolated from concurrent forks + // writing to the shared `role` / `member` / `organization` tables + // (tests/CLAUDE.md "Shared-table isolation"). + const memberTenantId = crypto.randomUUID(); + const foreignTenantId = crypto.randomUUID(); + + let memberRoleId: string; + let foreignRoleId: string; + + // Operator with a membership in memberTenantId but NO active organization + // (single-tenant → org plugin off → set-active does not exist). + let operatorSession: ApiTestSession; + + const originalEnv: Record = {}; + + beforeAll(async () => { + originalEnv.FEATURE_MULTI_TENANCY_ENABLED = process.env.FEATURE_MULTI_TENANCY_ENABLED; + originalEnv.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; + originalEnv.APP_BASE_URL = process.env.APP_BASE_URL; + // Pin single-tenant BEFORE the app-module graph is evaluated. + process.env.FEATURE_MULTI_TENANCY_ENABLED = "false"; + process.env.BETTER_AUTH_SECRET ??= + "test-better-auth-secret-for-testing-purposes-only-1234567890abcd"; + process.env.APP_BASE_URL ??= "http://localhost:3000"; + + const { bootstrap } = await import("../src/core/app/bootstrap.js"); + app = await bootstrap({ listen: false, logger: SILENT_LOGGER }); + prisma = app.get(PrismaService); + + // Operator: member of memberTenantId, no set-active. + operatorSession = await createApiTestSession(app.getHttpServer(), { + email: `hub-single-${Date.now()}@example.com`, + name: "Hub Single Tenant Operator", + }); + await ensureOrganizationMember(prisma, { + organizationId: memberTenantId, + userId: operatorSession.userId, + }); + memberRoleId = ( + await prisma.role.create({ + data: { name: `member-role-${crypto.randomUUID()}`, tenantId: memberTenantId }, + }) + ).id; + + // Foreign tenant + role the operator is NOT a member of. + await prisma.organization.create({ + data: { + id: foreignTenantId, + name: `hub-foreign-${foreignTenantId}`, + slug: `hub-foreign-${foreignTenantId}`, + createdAt: new Date(), + }, + }); + foreignRoleId = ( + await prisma.role.create({ + data: { name: `foreign-role-${crypto.randomUUID()}`, tenantId: foreignTenantId }, + }) + ).id; + }); + + afterAll(async () => { + try { + const tenants = [memberTenantId, foreignTenantId]; + await prisma.role.deleteMany({ where: { tenantId: { in: tenants } } }); + await prisma.member.deleteMany({ where: { organizationId: { in: tenants } } }); + await prisma.organization.deleteMany({ where: { id: { in: tenants } } }); + } catch { + // best-effort cleanup + } + await app?.close(); + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("GET /admin/roles resolves the operator's membership tenant (was 400)", async () => { + const res = await operatorSession.agent.get("/admin/roles").set("x-test-ability", "full"); + expect(res.status).toBe(200); + const ids = (res.body as Array<{ id: string }>).map((r) => r.id); + expect(ids).toContain(memberRoleId); + }); + + it("does NOT leak a foreign tenant's roles through the membership fallback", async () => { + const res = await operatorSession.agent.get("/admin/roles").set("x-test-ability", "full"); + expect(res.status).toBe(200); + const ids = (res.body as Array<{ id: string }>).map((r) => r.id); + expect(ids).not.toContain(foreignRoleId); + }); + + it("still 4xx for an authenticated user with no membership at all (no blind fallback)", async () => { + // With the interceptor now passing through when no tenant is + // resolvable, this 400 is raised by the handler's + // `requireTenantContext()` (a tenant-REQUIRED route), NOT by the + // interceptor — the membership-less contract is preserved either way. + const stranger = await createApiTestSession(app.getHttpServer(), { + email: `hub-single-no-membership-${Date.now()}@example.com`, + name: "Hub Single No Membership", + }); + const res = await stranger.agent.get("/admin/roles").set("x-test-ability", "full"); + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/tenant/i); + }); + + it("does NOT 400 a @Public tenant-OPTIONAL hub probe for a membership-less operator", async () => { + // Regression guard for the interceptor pass-through: `/hub/portal-access.json` + // is @Public and matched by `isHubPortalProtectedPath`, but it decides hub + // access and MUST answer WITHOUT a tenant. A membership-less operator gets a + // snapshot (hub=false), never a 400 — proving pass-through does not break + // tenant-optional probes the way the old fail-closed throw did. + const stranger = await createApiTestSession(app.getHttpServer(), { + email: `hub-single-probe-${Date.now()}@example.com`, + name: "Hub Single Probe", + }); + const res = await stranger.agent.get("/hub/portal-access.json"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ hub: expect.any(Boolean), tenantAdmin: expect.any(Boolean) }); + }); +}); diff --git a/tests/stories/hub-session-bootstrap.story.test.ts b/tests/stories/hub-session-bootstrap.story.test.ts index c5fc1c3d..b051e39f 100644 --- a/tests/stories/hub-session-bootstrap.story.test.ts +++ b/tests/stories/hub-session-bootstrap.story.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { pickDefaultOrganizationId } from "../../src/core/dx/clients/lib/hub-session-bootstrap.js"; +import { + bootstrapHubOperatorSession, + pickDefaultOrganizationId, +} from "../../src/core/dx/clients/lib/hub-session-bootstrap.js"; describe("Story · pickDefaultOrganizationId()", () => { it("prefers the seeded lenne org when present", () => { @@ -16,3 +19,107 @@ describe("Story · pickDefaultOrganizationId()", () => { expect(id).toBe("only"); }); }); + +describe("Story · bootstrapHubOperatorSession()", () => { + // Each case owns its own fetch fake (tests/CLAUDE.md: per-test fakes). + let fetchMock: ReturnType; + + const jsonResponse = (body: unknown, ok = true): Response => + ({ ok, json: async () => body }) as unknown as Response; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("MULTI-TENANT: lists orgs, set-actives the default, returns its id (path unchanged)", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/auth/organization/list") { + return Promise.resolve(jsonResponse([{ id: "org-1", slug: "lenne" }])); + } + if (url === "/api/auth/organization/set-active") { + return Promise.resolve(jsonResponse({ ok: true })); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const result = await bootstrapHubOperatorSession(); + + expect(result).toBe("org-1"); + const urls = fetchMock.mock.calls.map((c) => c[0]); + expect(urls).toContain("/api/auth/organization/set-active"); + // Multi-tenant path must NOT consult the single-tenant probe. + expect(urls).not.toContain("/hub/operator-tenant.json"); + }); + + it("SINGLE-TENANT: org/list 404 → resolves tenant via /hub/operator-tenant.json", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/auth/organization/list") { + return Promise.resolve(jsonResponse({}, false)); // 404 — org plugin off + } + if (url === "/hub/operator-tenant.json") { + return Promise.resolve(jsonResponse({ tenantId: "tenant-abc" })); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const result = await bootstrapHubOperatorSession(); + + expect(result).toBe("tenant-abc"); + const urls = fetchMock.mock.calls.map((c) => c[0]); + expect(urls).toContain("/hub/operator-tenant.json"); + // No set-active in single-tenant mode (the org plugin does not exist). + expect(urls).not.toContain("/api/auth/organization/set-active"); + }); + + it("SINGLE-TENANT: empty org list also falls back to the operator-tenant probe", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/auth/organization/list") { + return Promise.resolve(jsonResponse([])); // ok but empty + } + if (url === "/hub/operator-tenant.json") { + return Promise.resolve(jsonResponse({ tenantId: "tenant-xyz" })); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const result = await bootstrapHubOperatorSession(); + + expect(result).toBe("tenant-xyz"); + expect(fetchMock.mock.calls.map((c) => c[0])).toContain("/hub/operator-tenant.json"); + }); + + it("SINGLE-TENANT: no membership (tenantId null) → undefined (no blind fallback)", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/auth/organization/list") { + return Promise.resolve(jsonResponse({}, false)); + } + if (url === "/hub/operator-tenant.json") { + return Promise.resolve(jsonResponse({ tenantId: null })); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const result = await bootstrapHubOperatorSession(); + + expect(result).toBeUndefined(); + }); + + it("SINGLE-TENANT: probe failure returns undefined (no throw)", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/auth/organization/list") { + return Promise.resolve(jsonResponse({}, false)); + } + if (url === "/hub/operator-tenant.json") { + return Promise.resolve(jsonResponse({}, false)); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await expect(bootstrapHubOperatorSession()).resolves.toBeUndefined(); + }); +});