From 3e229d0de1d29723703a2f7889694ebef78b92a1 Mon Sep 17 00:00:00 2001 From: "Najjar\\NajjarV02" Date: Fri, 22 May 2026 12:37:11 +0400 Subject: [PATCH] feat(middleware): resolve workspace from Host on tenant subdomain When a visitor lands on .reparee.com without prior handoff, edge middleware HMAC-signs the subdomain and calls SaaS /api/saas/workspaces/by-subdomain/ to resolve workspace_uuid and api_base_url, sets them as HttpOnly cookies. Direct login (skipping handoff) now works because /api/proxy/* has workspace context from the first request. Skips on: - workspace_uuid cookie already present - non-tenant hosts (apex, localhost, www, "tenant" fallback) - SAAS_SHARED_SECRET env missing (degraded: returns to 412) Requires SaaS endpoint /api/saas/workspaces/by-subdomain/{sub} (new WorkspaceBySubdomainController) and SAAS_SHARED_SECRET env in Coolify (server-side only, not NEXT_PUBLIC_). --- apps/dashboard/middleware.ts | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 apps/dashboard/middleware.ts 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("") +}