feat(middleware): resolve workspace from Host on tenant subdomain
When a visitor lands on <subdomain>.reparee.com without prior
handoff, edge middleware HMAC-signs the subdomain and calls SaaS
/api/saas/workspaces/by-subdomain/<sub> 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_).
This commit is contained in:
parent
a888157d33
commit
3e229d0de1
99
apps/dashboard/middleware.ts
Normal file
99
apps/dashboard/middleware.ts
Normal file
@ -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 <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/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/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("")
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user