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