garage-erp/apps/dashboard/middleware.ts
Claude Code 7b145c1469 fix(dashboard): use /api/v1/saas path for subdomain workspace resolution
The edge middleware resolved the workspace by calling the SaaS landlord at
/api/saas/workspaces/by-subdomain/<sub>, but the landlord serves that route
under /api/v1/saas/... (the garage backend already calls it with /v1). The
missing version segment returned 404, so no workspace_uuid/api_base_url
cookies were set and the proxy responded "Workspace session not found",
blocking email/password login on a cold visit to <sub>.reparee.com/login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 06:51:56 +00:00

100 lines
3.5 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server"
/**
* Edge middleware that establishes workspace context from the request Host
* when a visitor lands on <subdomain>.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/v1/saas/workspaces/by-subdomain/<sub>` 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/v1/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<string> {
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("")
}