From c826ead1ffd510223b1f2c2c7cf99a40196c43b9 Mon Sep 17 00:00:00 2001 From: Martin Wedvich Date: Sat, 21 Mar 2026 17:36:08 +0100 Subject: [PATCH] feat(api,web): add tenant-slug-based routing with per-request tenant context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes are now /:tenantSlug/* (e.g. /dev-tenant/settings). Public routes (/login, /register) stay at root level. A new tenant-context middleware resolves the X-Tenant-Slug header per request, validates membership, and sets req.tenantContext — the session stores only identity, not tenant context. This enables multi-tab support with different tenants. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/src/auth/google-callback-handler.ts | 16 ++- packages/api/src/auth/slug.ts | 5 + packages/api/src/index.ts | 2 + packages/api/src/middleware/tenant-context.ts | 114 ++++++++++++++++++ packages/api/src/routes/sync.test.ts | 5 +- packages/api/src/routes/sync.ts | 4 +- packages/api/src/routes/user.test.ts | 5 +- packages/api/src/routes/user.ts | 5 +- packages/api/src/session-context.ts | 6 + packages/web/src/App.tsx | 9 +- packages/web/src/api/fetch.ts | 13 +- packages/web/src/components/RootRedirect.tsx | 14 +++ packages/web/src/components/TenantShell.tsx | 43 +++++++ packages/web/src/main.tsx | 5 +- packages/web/src/pages/HomePage.tsx | 2 +- packages/web/src/pages/LoginPage.tsx | 9 +- packages/web/src/pages/RegisterPage.tsx | 7 +- packages/web/src/pages/SettingsPage.tsx | 2 +- packages/web/src/sync/SyncBootstrap.tsx | 6 - packages/web/src/sync/api.ts | 2 +- packages/web/src/sync/sync-store.ts | 8 ++ packages/web/src/sync/use-sync-bootstrap.ts | 21 +++- 22 files changed, 265 insertions(+), 38 deletions(-) create mode 100644 packages/api/src/middleware/tenant-context.ts create mode 100644 packages/web/src/components/RootRedirect.tsx create mode 100644 packages/web/src/components/TenantShell.tsx delete mode 100644 packages/web/src/sync/SyncBootstrap.tsx diff --git a/packages/api/src/auth/google-callback-handler.ts b/packages/api/src/auth/google-callback-handler.ts index bb1ac5f..3668f22 100644 --- a/packages/api/src/auth/google-callback-handler.ts +++ b/packages/api/src/auth/google-callback-handler.ts @@ -93,8 +93,14 @@ export function googleCallbackHandler( return; } + const regSlugResult = await db.query<{ slug: string }>( + `SELECT slug FROM tenants WHERE id = $1`, + [result.tenantId], + ); + const regSlug = regSlugResult.rows[0]?.slug; + res.cookie(COOKIE_NAME, result.sessionToken, cookieOptions()); - res.redirect(returnTo); + res.redirect(regSlug ? `/${regSlug}${returnTo === "/" ? "/" : returnTo}` : returnTo); } else { // --- Login flow --- let identity; @@ -153,8 +159,14 @@ export function googleCallbackHandler( detail, }); + const loginSlugResult = await db.query<{ slug: string }>( + `SELECT slug FROM tenants WHERE id = $1`, + [membership.tenant_id], + ); + const loginSlug = loginSlugResult.rows[0]?.slug; + res.cookie(COOKIE_NAME, token, cookieOptions()); - res.redirect(returnTo); + res.redirect(loginSlug ? `/${loginSlug}${returnTo === "/" ? "/" : returnTo}` : returnTo); } } catch (err) { req.log.error({ err }, "Unexpected error in Google callback handler"); diff --git a/packages/api/src/auth/slug.ts b/packages/api/src/auth/slug.ts index f9d2799..f207b7c 100644 --- a/packages/api/src/auth/slug.ts +++ b/packages/api/src/auth/slug.ts @@ -10,9 +10,14 @@ const RESERVED_SLUGS = new Set([ "app", "auth", "billing", + "callback", "dashboard", + "error", + "health", "help", + "invite", "login", + "logout", "register", "settings", "status", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index e40f5df..95d2977 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -7,6 +7,7 @@ import { logger } from "./logger.ts"; import { requestLogger } from "./middleware/request-logger.ts"; import { requestContextMiddleware } from "./middleware/request-context.ts"; import { sessionMiddleware } from "./middleware/session.ts"; +import { tenantContextMiddleware } from "./middleware/tenant-context.ts"; import { createAuthRouter } from "./routes/auth.ts"; import { createTenantsRouter } from "./routes/tenants.ts"; import { createSyncRouter } from "./routes/sync.ts"; @@ -54,6 +55,7 @@ app.use(express.urlencoded({ extended: false })); app.use(requestLogger); app.use(requestContextMiddleware); app.use(sessionMiddleware); +app.use(tenantContextMiddleware); app.get("/api/health", (_req, res) => { res.json({ status: "ok" }); diff --git a/packages/api/src/middleware/tenant-context.ts b/packages/api/src/middleware/tenant-context.ts new file mode 100644 index 0000000..f0ae782 --- /dev/null +++ b/packages/api/src/middleware/tenant-context.ts @@ -0,0 +1,114 @@ +import type { RequestHandler } from "express"; +import { pool } from "../db.ts"; +import type { TenantContext } from "../session-context.ts"; + +const CACHE_TTL_MS = 60 * 1000; + +interface MembershipCacheEntry { + tenantId: string; + role: string; + cachedAt: number; +} + +// Keyed by "principalId:tenantId" for ID-based lookups, +// "principalId:slug:" for slug-based lookups +const membershipCache = new Map(); + +function getCached(key: string): MembershipCacheEntry | null | undefined { + const entry = membershipCache.get(key); + if (entry === undefined) return undefined; + if (entry !== null && Date.now() - entry.cachedAt > CACHE_TTL_MS) { + membershipCache.delete(key); + return undefined; + } + // Null entries (no membership) also expire + if (entry === null) { + // We store null with a timestamp trick — just expire after TTL + // For simplicity, re-check by not caching negatives + membershipCache.delete(key); + return undefined; + } + return entry; +} + +async function resolveTenantBySlug( + principalId: string, + slug: string, +): Promise { + const slugKey = `${principalId}:slug:${slug}`; + const cached = getCached(slugKey); + if (cached !== undefined) return cached; + + const result = await pool.query<{ tenant_id: string; role: string }>( + `SELECT m.tenant_id, m.role + FROM memberships m + JOIN tenants t ON t.id = m.tenant_id + WHERE m.principal_id = $1 AND t.slug = $2 AND t.status = 'active'`, + [principalId, slug], + ); + + const row = result.rows[0]; + if (!row) return null; + + const entry: MembershipCacheEntry = { + tenantId: row.tenant_id, + role: row.role, + cachedAt: Date.now(), + }; + membershipCache.set(slugKey, entry); + return { tenantId: entry.tenantId, role: entry.role }; +} + +async function resolveTenantById( + principalId: string, + tenantId: string, +): Promise { + const idKey = `${principalId}:${tenantId}`; + const cached = getCached(idKey); + if (cached !== undefined) return cached; + + const result = await pool.query<{ role: string }>( + `SELECT m.role + FROM memberships m + JOIN tenants t ON t.id = m.tenant_id + WHERE m.principal_id = $1 AND m.tenant_id = $2 AND t.status = 'active'`, + [principalId, tenantId], + ); + + const row = result.rows[0]; + if (!row) return null; + + const entry: MembershipCacheEntry = { + tenantId, + role: row.role, + cachedAt: Date.now(), + }; + membershipCache.set(idKey, entry); + return { tenantId, role: entry.role }; +} + +export const tenantContextMiddleware: RequestHandler = async (req, res, next) => { + if (!req.session) { + next(); + return; + } + + const slug = req.headers["x-tenant-slug"]; + const { principalId, tenantId: defaultTenantId } = req.session; + + let tenantContext: TenantContext | null; + + if (typeof slug === "string" && slug) { + tenantContext = await resolveTenantBySlug(principalId, slug); + } else { + tenantContext = await resolveTenantById(principalId, defaultTenantId); + } + + if (!tenantContext) { + res.status(403).json({ error: "no_membership" }); + return; + } + + req.tenantContext = tenantContext; + next(); +}; diff --git a/packages/api/src/routes/sync.test.ts b/packages/api/src/routes/sync.test.ts index ea1906d..8ac56e4 100644 --- a/packages/api/src/routes/sync.test.ts +++ b/packages/api/src/routes/sync.test.ts @@ -21,7 +21,10 @@ const SESSION: SessionContext = { function makeApp(sessionOverride: SessionContext | null, pool: ReturnType) { const app = express(); app.use((req, _res, next) => { - if (sessionOverride) req.session = sessionOverride; + if (sessionOverride) { + req.session = sessionOverride; + req.tenantContext = { tenantId: sessionOverride.tenantId, role: "owner" }; + } next(); }); app.use("/api/sync", createSyncRouter(pool, kms)); diff --git a/packages/api/src/routes/sync.ts b/packages/api/src/routes/sync.ts index 4c249ab..ebd8263 100644 --- a/packages/api/src/routes/sync.ts +++ b/packages/api/src/routes/sync.ts @@ -15,12 +15,12 @@ export function createSyncRouter(pool: Pool, kms: KeyManagementService): Router const router = Router(); router.get("/bootstrap", async (req, res) => { - if (!req.session) { + if (!req.tenantContext) { res.status(401).json({ error: "not_authenticated" }); return; } - const { tenantId } = req.session; + const { tenantId } = req.tenantContext; const client = await pool.connect(); let events: HydratedTenantEvent[]; diff --git a/packages/api/src/routes/user.test.ts b/packages/api/src/routes/user.test.ts index 9875f5c..a22d85c 100644 --- a/packages/api/src/routes/user.test.ts +++ b/packages/api/src/routes/user.test.ts @@ -21,7 +21,10 @@ const SESSION: SessionContext = { function makeApp(sessionOverride: SessionContext | null, pool: ReturnType) { const app = express(); app.use((req, _res, next) => { - if (sessionOverride) req.session = sessionOverride; + if (sessionOverride) { + req.session = sessionOverride; + req.tenantContext = { tenantId: sessionOverride.tenantId, role: "owner" }; + } next(); }); app.use("/api/user", createUserRouter(pool, kms)); diff --git a/packages/api/src/routes/user.ts b/packages/api/src/routes/user.ts index 7d6c070..c8d3b78 100644 --- a/packages/api/src/routes/user.ts +++ b/packages/api/src/routes/user.ts @@ -7,12 +7,13 @@ export function createUserRouter(pool: Pool, kms: KeyManagementService): Router const router = Router(); router.get("/me/events", async (req, res) => { - if (!req.session) { + if (!req.session || !req.tenantContext) { res.status(401).json({ error: "not_authenticated" }); return; } - const { principalId, tenantId } = req.session; + const { principalId } = req.session; + const { tenantId } = req.tenantContext; const rawAfterVersion = req.query.afterVersion; const afterVersion = diff --git a/packages/api/src/session-context.ts b/packages/api/src/session-context.ts index 4e32cd2..9678b3d 100644 --- a/packages/api/src/session-context.ts +++ b/packages/api/src/session-context.ts @@ -7,10 +7,16 @@ export interface SessionContext { expiresAt: Date; } +export interface TenantContext { + tenantId: string; + role: string; +} + declare global { namespace Express { interface Request { session?: SessionContext; + tenantContext?: TenantContext; requestContext: RequestContext; } } diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index aa04087..6e92224 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,5 +1,7 @@ import { Route, Routes } from "react-router"; import { RequireAuth } from "./components/RequireAuth"; +import { RootRedirect } from "./components/RootRedirect"; +import { TenantShell } from "./components/TenantShell"; import { HomePage } from "./pages/HomePage"; import { LoginPage } from "./pages/LoginPage"; import { RegisterPage } from "./pages/RegisterPage"; @@ -10,9 +12,12 @@ export function App() { } /> } /> + } /> }> - } /> - } /> + }> + } /> + } /> + ); diff --git a/packages/web/src/api/fetch.ts b/packages/web/src/api/fetch.ts index 765df2a..e85f24b 100644 --- a/packages/web/src/api/fetch.ts +++ b/packages/web/src/api/fetch.ts @@ -1,13 +1,22 @@ import { generateTraceId, CORRELATION_HEADER } from "@heim/logging"; +let activeTenantSlug: string | undefined; + +export function setActiveTenantSlug(slug: string | undefined): void { + activeTenantSlug = slug; +} + /** - * Thin wrapper around `fetch` that attaches a correlation ID to every request. - * The ID is a W3C-compatible trace-id (32 hex chars) generated fresh per call. + * Thin wrapper around `fetch` that attaches a correlation ID and tenant slug + * to every request. */ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const headers = new Headers(init?.headers); if (!headers.has(CORRELATION_HEADER)) { headers.set(CORRELATION_HEADER, generateTraceId()); } + if (activeTenantSlug) { + headers.set("x-tenant-slug", activeTenantSlug); + } return fetch(input, { ...init, headers }); } diff --git a/packages/web/src/components/RootRedirect.tsx b/packages/web/src/components/RootRedirect.tsx new file mode 100644 index 0000000..3451675 --- /dev/null +++ b/packages/web/src/components/RootRedirect.tsx @@ -0,0 +1,14 @@ +import { Navigate } from "react-router"; +import { useAuth } from "../auth/auth-context"; + +export function RootRedirect(): React.ReactElement { + const { status, session } = useAuth(); + + if (status === "loading") return

Loading…

; + + if (status === "authenticated" && session?.tenant) { + return ; + } + + return ; +} diff --git a/packages/web/src/components/TenantShell.tsx b/packages/web/src/components/TenantShell.tsx new file mode 100644 index 0000000..995c89f --- /dev/null +++ b/packages/web/src/components/TenantShell.tsx @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import { Link, Outlet, useParams } from "react-router"; +import { useAuth } from "../auth/auth-context"; +import { setActiveTenantSlug } from "../api/fetch"; +import { useSyncBootstrap } from "../sync/use-sync-bootstrap"; + +export function TenantShell(): React.ReactElement { + const { tenantSlug } = useParams<{ tenantSlug: string }>(); + const { session } = useAuth(); + + useEffect(() => { + setActiveTenantSlug(tenantSlug); + return () => setActiveTenantSlug(undefined); + }, [tenantSlug]); + + const bootstrapStatus = useSyncBootstrap(tenantSlug!); + + if (bootstrapStatus === "denied") { + const defaultSlug = session?.tenant?.slug; + return ( +
+

Access Denied

+

You don't have access to this workspace.

+ {defaultSlug && Go to your workspace} +
+ ); + } + + if (bootstrapStatus === "loading") { + return

Loading…

; + } + + if (bootstrapStatus === "error") { + return ( +
+

Something went wrong

+

Failed to load workspace data. Please try refreshing.

+
+ ); + } + + return ; +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index e09cb16..5563dc6 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -4,15 +4,12 @@ import { BrowserRouter } from "react-router"; import { App } from "./App"; import "./index.css"; import { AuthProvider } from "./auth/auth-context"; -import { SyncBootstrap } from "./sync/SyncBootstrap"; createRoot(document.getElementById("root")!).render( - - - + , diff --git a/packages/web/src/pages/HomePage.tsx b/packages/web/src/pages/HomePage.tsx index 2cc7276..eca11d6 100644 --- a/packages/web/src/pages/HomePage.tsx +++ b/packages/web/src/pages/HomePage.tsx @@ -29,7 +29,7 @@ export const HomePage = observer(function HomePage() {

)}
- + Settings