From b105a5136c85a529ca57335ce6ef78b15e943097 Mon Sep 17 00:00:00 2001 From: Pascal Klesse Date: Thu, 16 Jul 2026 09:08:45 +0200 Subject: [PATCH] feat(multi-tenancy): make the core Hub/admin console work in single-tenant deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/openapi.snapshot.json | 14 ++ src/core/app/app.module.ts | 10 ++ .../dx/clients/lib/hub-session-bootstrap.ts | 37 +++- src/core/dx/hub.controller.ts | 36 ++++ .../hub/hub-operator-tenant.interceptor.ts | 143 ++++++++++++++++ tests/hub-operator-tenant-single.e2e-spec.ts | 159 ++++++++++++++++++ .../hub-session-bootstrap.story.test.ts | 111 +++++++++++- 7 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 src/core/hub/hub-operator-tenant.interceptor.ts create mode 100644 tests/hub-operator-tenant-single.e2e-spec.ts 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(); + }); +});