Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/api/src/auth/google-callback-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
5 changes: 5 additions & 0 deletions packages/api/src/auth/slug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ const RESERVED_SLUGS = new Set([
"app",
"auth",
"billing",
"callback",
"dashboard",
"error",
"health",
"help",
"invite",
"login",
"logout",
"register",
"settings",
"status",
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" });
Expand Down
114 changes: 114 additions & 0 deletions packages/api/src/middleware/tenant-context.ts
Original file line number Diff line number Diff line change
@@ -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:<slug>" for slug-based lookups
const membershipCache = new Map<string, MembershipCacheEntry | null>();

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<TenantContext | null> {
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<TenantContext | null> {
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();
};
5 changes: 4 additions & 1 deletion packages/api/src/routes/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ const SESSION: SessionContext = {
function makeApp(sessionOverride: SessionContext | null, pool: ReturnType<typeof makePool>) {
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));
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/routes/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
5 changes: 4 additions & 1 deletion packages/api/src/routes/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ const SESSION: SessionContext = {
function makeApp(sessionOverride: SessionContext | null, pool: ReturnType<typeof makePool>) {
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));
Expand Down
5 changes: 3 additions & 2 deletions packages/api/src/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/session-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
9 changes: 7 additions & 2 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,9 +12,12 @@ export function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<RootRedirect />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/:tenantSlug" element={<TenantShell />}>
<Route index element={<HomePage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Route>
</Routes>
);
Expand Down
13 changes: 11 additions & 2 deletions packages/web/src/api/fetch.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 });
}
14 changes: 14 additions & 0 deletions packages/web/src/components/RootRedirect.tsx
Original file line number Diff line number Diff line change
@@ -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 <p>Loading…</p>;

if (status === "authenticated" && session?.tenant) {
return <Navigate to={`/${session.tenant.slug}/`} replace />;
}

return <Navigate to="/login" replace />;
}
43 changes: 43 additions & 0 deletions packages/web/src/components/TenantShell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ padding: 32 }}>
<h1>Access Denied</h1>
<p>You don't have access to this workspace.</p>
{defaultSlug && <Link to={`/${defaultSlug}/`}>Go to your workspace</Link>}
</div>
);
}

if (bootstrapStatus === "loading") {
return <p>Loading…</p>;
}

if (bootstrapStatus === "error") {
return (
<div style={{ padding: 32 }}>
<h1>Something went wrong</h1>
<p>Failed to load workspace data. Please try refreshing.</p>
</div>
);
}

return <Outlet />;
}
5 changes: 1 addition & 4 deletions packages/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<SyncBootstrap>
<App />
</SyncBootstrap>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const HomePage = observer(function HomePage() {
</p>
)}
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<Link to="/settings" style={{ padding: "8px 16px" }}>
<Link to="settings" style={{ padding: "8px 16px" }}>
Settings
</Link>
<button onClick={() => void logout()} style={{ padding: "8px 16px", cursor: "pointer" }}>
Expand Down
Loading