diff --git a/apps/dashboard/middleware.ts b/apps/dashboard/middleware.ts new file mode 100644 index 0000000..1d261b6 --- /dev/null +++ b/apps/dashboard/middleware.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server" + +/** + * Edge middleware that establishes workspace context from the request Host + * when a visitor lands on .reparee.com WITHOUT a prior handoff. + * + * - Skips if the workspace_uuid cookie is already set (handoff already happened). + * - Skips for the shared fallback host `tenant.reparee.com` and any non-tenant + * host (apex, localhost, preview URLs). + * - For a tenant subdomain, HMAC-signs the subdomain and calls SaaS + * `GET /api/saas/workspaces/by-subdomain/` to resolve to + * { workspace_uuid, api_base_url }, then sets those as HttpOnly cookies. + * + * Matcher excludes /api/proxy/* (proxy needs no resolution; cookies already set) + * and /activate/* (the handoff itself sets the cookies via the page handler). + */ +export const config = { + matcher: [ + "/((?!api/proxy|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|activate).*)", + ], +} + +const SUBDOMAIN_RE = /^([a-z0-9-]+)\.reparee\.com$/i +const SKIP_SUBDOMAINS = new Set(["tenant", "www"]) +const COOKIE_MAX_AGE = 60 * 60 * 24 * 7 // 7 days + +export async function middleware(req: NextRequest) { + if (req.cookies.get("workspace_uuid")) { + return NextResponse.next() + } + + const host = (req.headers.get("host") || "").toLowerCase() + const match = host.match(SUBDOMAIN_RE) + if (!match) return NextResponse.next() + + const sub = match[1].toLowerCase() + if (SKIP_SUBDOMAINS.has(sub)) return NextResponse.next() + + const saasUrl = process.env.NEXT_PUBLIC_SAAS_URL || "https://reparee.com" + const secret = process.env.SAAS_SHARED_SECRET || "" + if (!secret) { + return NextResponse.next() + } + + let workspaceUuid: string | undefined + let apiBaseUrl: string | undefined + + try { + const signature = await hmacSha256Hex(sub, secret) + const resolveUrl = `${saasUrl.replace(/\/$/, "")}/api/saas/workspaces/by-subdomain/${encodeURIComponent(sub)}` + const res = await fetch(resolveUrl, { + headers: { "X-SaaS-Signature": signature, Accept: "application/json" }, + cache: "no-store", + signal: AbortSignal.timeout(2500), + }) + if (!res.ok) return NextResponse.next() + + const data = (await res.json()) as { ok?: boolean; workspace_uuid?: string; api_base_url?: string } + if (!data.ok || !data.workspace_uuid || !data.api_base_url) { + return NextResponse.next() + } + workspaceUuid = data.workspace_uuid + apiBaseUrl = data.api_base_url + } catch { + return NextResponse.next() + } + + const response = NextResponse.next() + response.cookies.set("workspace_uuid", workspaceUuid!, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: COOKIE_MAX_AGE, + }) + response.cookies.set("api_base_url", apiBaseUrl!, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: COOKIE_MAX_AGE, + }) + return response +} + +async function hmacSha256Hex(message: string, key: string): Promise { + const enc = new TextEncoder() + const cryptoKey = await crypto.subtle.importKey( + "raw", + enc.encode(key), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ) + const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message)) + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +}