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