Saltar para o conteúdo
ErmisAI

Route surfaces, proxy and auth gates

The four route surfaces, how src/proxy.ts combines Clerk and next-intl, and why every API route carries its own gate.

ErmisAI serves four route surfaces from one Next.js App Router tree. One middleware file composes Clerk authentication with next-intl routing, injects the CSP nonce, and does nothing else. Authorisation lives entirely in pages and route handlers.

If you take one thing from this page: the middleware is not your auth gate. Its route matchers are documented in-code as defense-in-depth (src/proxy.ts:3-6), and a CI test fails the build when a new API route ships without its own check.

The four route surfaces

All four share the root layout at src/app/layout.tsx.

SurfaceDirectoryLocale prefixWho reaches it
Public marketing and legalsrc/app/[locale]/Always (localePrefix: 'always')Anonymous
Newsroom workspacesrc/app/(tenant)/app/NoneAny signed-in user with a completed newsroom
Platform operationssrc/app/(internal)/admin/NoneClerk claim app_rolesuper_admin, editor, ops
APIsrc/app/api/NonePer-handler

The public surface has 14 content pages — /, /pricing, /lyra, /partners, /about, /contact, /careers, /security, /terms, /privacy, /cookies, /gdpr, /subprocessors, /ai-policy — plus the three (auth) routes /sign-in, /sign-up, and /waitlist, in ten locales: en, el, pl, it, es, pt, sv, da, nb, fi.

Outside the four surfaces sit /auth/continue (a post-auth redirect handler), /unsubscribe (a route handler returning hand-written HTML, not a React page), /.well-known/vercel/flags, and the metadata routes robots.txt, sitemap.xml, manifest.webmanifest, and [locale]/opengraph-image.

src/app/api currently holds 88 route.ts files.

Middleware: src/proxy.ts

The file is src/proxy.ts, not middleware.ts — the Next.js 16 proxy convention. It exports a default clerkMiddleware(async (auth, request) => …) and a config.matcher:

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
}

Clerk's createRouteMatcher is deprecated. It is retained here on purpose, behind an oxlint-disable with the rationale in the file header: every route already carries its own gate, so the matchers are additive defence, not the primary control.

What runs, in order

Resolve the session

await auth() (src/proxy.ts:218). Nothing is protected yet; this only reads state.

Pick a nonce

Cacheable marketing routes get the deployment-stable nonce; every other route gets a fresh crypto.randomUUID() (src/proxy.ts:221-224). See Marketing edge cache and the stable nonce.

Build and inject the CSP

The full policy is assembled at src/proxy.ts:225-271 and set on both the request headers and the response.

Redirect signed-in users off the landing page

If the path is / or exactly /{locale} and authState.userId exists, return a 307 to /app (src/proxy.ts:293-299 calls NextResponse.redirect(new URL('/app', request.url)) with no status argument, and NextResponse.redirect defaults to 307). Doing this in middleware is why the marketing landing page contains no auth() call on its render path.

Short-circuit public routes

isPublicRoute(request)NextResponse.next() with the headers applied and no auth (src/proxy.ts:301-307).

Run next-intl for locale-bearing routes

If the request is not a non-localized route: an unprefixed path runs handleI18nRouting (which redirects /pricing/en/pricing); an already-prefixed isLocalizedPublicRoute passes through, with edge-cache headers added when it is also cacheable (src/proxy.ts:312-323).

Otherwise, require a session

await auth.protect({ unauthenticatedUrl: buildUnauthenticatedUrl(request) }) (src/proxy.ts:331-333). buildUnauthenticatedUrl resolves to buildAppUrl(`/${routing.defaultLocale}/sign-in`, request.url).toString() (src/proxy.ts:195-197) — i.e. always /en/sign-in, regardless of the visitor's locale.

The four route matcher lists

MatcherLinesContentsEffect
isLocalizedPublicRoute27-49The 14 content pages, opengraph-image(.*), twitter-image(.*), sign-in(.*), sign-up(.*), waitlist(.*)Anonymous access, next-intl routing applied
isCacheableMarketingRoute54-69The same 14 content pages onlyAdds CDN cache headers and the stable nonce
isNonLocalizedRoute71-78/api, /admin, /app, /auth, /monitoring, /.well-knownSkips next-intl entirely
isPublicRoute132-178Icons, robots.txt, sitemap.xml, manifests, /unsubscribe, /api/health, three webhooks, five cron routes, the CMS/WordPress M2M routes, /api/openapi/public-integrations, /api/queues/(.*), /monitoring/sentry(.*)No Clerk protection at all — the handler self-protects

isCacheableMarketingRoute deliberately excludes sign-in, sign-up, and waitlist: Clerk's auth flows must stay dynamic and cookie-bearing.

The cron and queue entries in isPublicRoute exist precisely so that a bearer token or an HMAC-signed payload reaches the handler that verifies it. Bypassing Clerk is the point; the gate is inside.

The three injected request headers

requestHeaders.set('Content-Security-Policy', contentSecurityPolicy)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('x-pathname', request.nextUrl.pathname)

x-pathname exists because Next.js does not expose the pathname to server components. The tenant layout reads it to run the onboarding redirect without looping on the onboarding route itself (src/app/(tenant)/app/layout.tsx:68-74).

The middleware also echoes the client telemetry headers X-Ermis-Source and X-Ermis-Trace back on every response (src/proxy.ts:180-193).

Adding a public marketing route

A new page file is not enough. A route that renders fine in development will redirect anonymous visitors to /en/sign-in in production if you skip step 2.

Create the page

src/app/[locale]/<slug>/page.tsx.

Add it to isLocalizedPublicRoute

src/proxy.ts:27-49. Without this the route falls through to auth.protect.

Add it to isCacheableMarketingRoute

src/proxy.ts:54-69, if the page is input-free and anonymous. Skip this for anything that needs to set a cookie.

Add it to src/app/sitemap.ts

Otherwise it is public but unlisted. /subprocessors is currently in this state — allowlisted in the proxy, absent from the sitemap's 13 pathnames.

More detail on the marketing surface: Public site, SEO and marketing routes.

Marketing edge cache and the stable nonce

Cacheable marketing responses get:

response.headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=300, stale-while-revalidate=86400')
response.headers.set('CDN-Cache-Control', 'public, s-maxage=300, stale-while-revalidate=86400')
response.headers.delete('set-cookie')

Two non-obvious decisions are bound together here.

Set-Cookie is deleted because Vercel refuses to cache a response that carries cookies, and both Clerk and next-intl attach cookies an anonymous, URL-localed page does not need (src/proxy.ts:205-215).

The nonce is deployment-stable on exactly those routes: base64("ermis-marketing-" + VERCEL_DEPLOYMENT_ID), falling back to local (src/proxy.ts:89-91). A per-request nonce could never match a CDN-cached HTML body, so every script would be blocked. The in-code rationale for accepting a shared nonce is that these pages are input-free — there is no injection sink for a known nonce to abuse — and strict-dynamic still applies. The value rotates on every deploy.

Any future feature that needs to set a cookie on one of the 14 cacheable marketing pages will silently lose it. Move the route out of isCacheableMarketingRoute first.

Why every page renders dynamically

The root layout reads headers() to get the nonce:

const nonce = (await headers()).get('x-nonce') ?? undefined

That is src/app/layout.tsx:63, and src/components/theme/ThemeScript.tsx:23 does the same. A headers() read opts the route out of static rendering, and the root layout is shared by all four surfaces — so nothing is statically generated.

generateStaticParams exists on the [locale] layout (src/app/[locale]/layout.tsx:11-13) and declares the ten locale params, but it does not make those pages static given the headers() read above. There is no export const revalidate anywhere in the tree. The marketing edge cache is what compensates.

ThemeScript sets both nonce={nonce} and suppressHydrationWarning, because browsers blank the DOM nonce attribute after applying the CSP and React would otherwise throw hydration error #418.

The layout tree

LayoutResponsibilities
src/app/layout.tsxShared by all four surfaces. Loads fonts, sets <html lang>, renders ThemeScript, wraps children in AppLocaleProviderClerkAppProvider (nonce passed to <ClerkProvider>) → LenisProvider
src/app/[locale]/layout.tsxValidates the locale with hasLocale()notFound(), calls setRequestLocale(locale), mounts RootHeader, RootFooter, CookieConsent
src/app/(tenant)/app/layout.tsxResolves session metadata and tenant scope, computes showEditorial / showMonitoring, enforces the onboarding gate, calls setRequestLocale(newsroomProfile.uiLocale), mounts AppTelemetryProvider and TenantShell
src/app/(internal)/admin/layout.tsxRedirects to /sign-in without a userId, filters the nav by canAccessAdminSurface, and renders a full-page access-denied card for non-admin roles

The tenant layout is the only place that calls setRequestLocale with a stored per-tenant value, which is how /app pages get the newsroom's UI locale without a [locale] URL segment. The admin layout does not call it at all — locale falls back to the ermis-ui-locale cookie tier in src/i18n/request.ts.

Each layout hands its NextIntlClientProvider only an allowlisted subset of message namespaces (src/lib/i18n/client-messages.ts): public gets common, nav, footer, auth, cookieConsent, home, pricing, animations; tenant and admin both get common, nav, footer, auth, cookieConsent, admin, app. tests/client-message-scoping.vitest.ts walks the real import graph of each route segment and fails CI if a client component reads a namespace outside its layout's list. See Localization system.

Every page and layout under src/app is a Server Component. The only files in that tree carrying 'use client' are five error boundaries: src/app/error.tsx, src/app/global-error.tsx, src/app/(internal)/admin/error.tsx, src/app/(tenant)/app/error.tsx, and src/app/[locale]/(auth)/error.tsx.

Where users land after authentication

clerkPostAuthRedirectPath = '/app' (src/lib/auth/redirect-paths.ts:1) is used as signInFallbackRedirectUrl and signUpFallbackRedirectUrl on <ClerkProvider> and on the Clerk widgets. ClerkAppProvider also sets locale-aware signInUrl / signUpUrl / waitlistUrl of the form /{uiLocale}/sign-in.

Both /app and /auth/continue are thin redirectors calling resolvePostAuthRedirectPath (src/lib/auth/post-auth-redirect.ts:56-89):

  1. Resolve the tenant scope from session claims.
  2. Load the newsroom profile. If isNewsroomProfileComplete is false → /app/onboarding, skipping Polar provisioning — the scope is not final until onboarding completes, because the user may still create an organization.
  3. Otherwise attempt Polar provisioning, wrapped in try/catch so an outage cannot block the redirect.
  4. Return /app/feed.

selectPostAuthRedirectPath takes a canReview flag, but the function no longer branches on it — everyone with a completed newsroom lands on /app/feed. The field is retained for callers. Do not document reviewers as landing anywhere else.

/admin is a separate redirector keyed on the platform role (src/lib/auth/platform-roles.ts:78-94): ops/admin/queues, editor/admin/review, super_admin/admin/review, unassigned/app/feed.

The onboarding gate is two layers deep

Both layers share one predicate, hasCompletedNewsroomSetup(profile, memberTitle) (src/lib/platform/newsroom-route-guard.ts:29-38) — a complete newsroom profile and a non-empty tenant_memberships.title.

  • Layer 1, the tenant layout. Redirects to /app/onboarding when userId && onboardingRequired && !isOnboardingRoute && currentPathname.startsWith('/app'), reading the path from the injected x-pathname header.
  • Layer 2, per-page guards. requireCompletedNewsroomSetup(userId, sessionClaims) on feed, editorial, editorial detail, story detail, monitoring, settings, and the WordPress connect page. The onboarding page itself uses the inverse guard requireIncompleteNewsroomSetup, which redirects a completed user to /app.

The comment in the guard records why they share a predicate: a divergence previously let a title-less member reach tenant surfaces while the onboarding page still held them as incomplete.

resolvePostAuthRedirectPath calls only isNewsroomProfileComplete, without the membership-title half. A user with a complete profile and no title therefore goes /app/app/feed → layout redirect → /app/onboarding. Not a loop, but an extra hop worth recognising in a trace.

The auth model

Two role axes, resolved differently

getResolvedClerkSessionMetadata(userId, sessionClaims) (src/lib/auth/clerk-session.ts:231-308) is the single entry point every server gate calls. It merges the live JWT with a database snapshot, and the split of authority is deliberate:

  • appRole always comes from the live Clerk claim app_role. Never from the snapshot. The comment states the reason: the snapshot is updated asynchronously by the Clerk webhook, so letting it win would let a demoted operator keep elevated access until the webhook lands — or indefinitely if it fails.
  • tenantRole and tenantTitle are database-authoritative, read from tenant_memberships keyed on the active organization's scope. Clerk's native org roles cannot express all three tenant roles, so the database holds the richer tier.

On a repository error the function degrades to claim-only metadata and disables the database path (clerk-session.ts:304-307).

Tenant scope

Tenant scope is a string key, not a row: org:<clerkOrgId> when the session carries the compact org claim o.id, otherwise user:<lowercased clerkUserId> (resolveTenantScopeIdFromSessionClaims, clerk-session.ts:334-356). A live session with no org claim is the personal workspace. The database snapshot fallback applies only to claim-less contexts — background jobs and webhooks — because falling through to it made the Clerk organization switcher cosmetic.

Isolation is tenant_scope_id filtering in application queries plus per-route gates. There is no row-level security and no database-level ACL. See Tenant scoping and isolation.

Least-privilege fallbacks

When no explicit tenant_role claim resolves (clerk-session.ts:215-217):

const tenantRole =
  resolvedTenantRole ??
  (org.id === null ? 'owner' : org.role === 'org:admin' ? 'owner' : 'member')

No org → owner of the personal workspace. Clerk org:adminowner. Anything else, including a custom Clerk org role, → member. The comment records that defaulting to owner here was a fail-open gate: a custom org:editor role would have received billing and team access.

A missing or unparseable app_role claim resolves to unassigned (resolvePlatformAppRole, platform-roles.ts:48-60), which reaches no admin surface.

Role gates by surface

Platform roles

adminSurfaceAccessMap (src/lib/auth/platform-roles.ts:25-35) is the whole matrix:

Surfacesuper_admineditorops
review
tenants
sources
clusters
queues
flags
ai
costs
deliveries

unassigned gets nothing — canAccessAdminSurface early-returns false. The admin layout renders only the nav items a role can reach, and a non-admin role gets a full-page card ("Operations access is restricted") instead of the shell. Middleware does not gate /admin beyond requiring a session, so any signed-in user can request /admin/*; there is no redirect and no 404.

More on these surfaces: The operations console.

Tenant roles

Three values only — owner, admin, member (src/lib/auth/newsroom-roles.ts:3, Postgres enum newsroom_tenant_role). hasAdminBypass(appRole) is appRole === 'super_admin' only; editor and ops get no tenant-side bypass anywhere.

Capability helperowneradminmember
canManageBilling
canManageIntegrations
canManageNewsroomSettings
canManageTenantUsers
canListTenantMembers
canViewNewsroomAnalytics
canAccessMonitoring
canReviewStories / canApproveStories
canAssignStories
canEditStories
canMutateStoryDraftany draftany draftown or unclaimed only

canApproveStories is a literal alias of canReviewStories (newsroom-roles.ts:197-199) — there is no separate approval permission, and self-approval is possible.

canAccessTenantSurface(appRole, tenantRole, surface) maps exactly four surfaces: teamcanManageTenantUsers, billingcanManageBilling, integrationscanManageIntegrations, editorialcanReviewStories.

doesDraftBelongToActor treats an ownerless draft as open to any member (a recorded decision: pipeline-synthesized drafts are an unclaimed pickup). The mutating route then claims ownership, after which the own-draft rule applies.

Pages do not redirect on a failed role check — they render AccessRestrictedState. The role side of this model is covered for end users in Team, roles and permissions.

Every API route carries its own gate

There is no global middleware auth gate for API routes. Each handler authenticates itself. The canonical shape, from src/app/api/team/route.ts:16-30:

export async function GET(request: Request) {
  beginRouteContext('api:team:get', request)
  try {
    const { userId, sessionClaims } = await auth()

    if (!userId) {
      return Response.json({ error: 'Unauthorized' }, { status: 401 })
    }

    const sessionMetadata = await getResolvedClerkSessionMetadata(userId, sessionClaims)

    if (!canListTenantMembers(sessionMetadata.appRole, sessionMetadata.tenantRole)) {
      return Response.json({ error: 'Forbidden' }, { status: 403 })
    }

Session check first, then the role helper, then plan gates and payload validation. beginRouteContext is not a gate — it starts the request-origin logging and OpenTelemetry span context. Error bodies follow the uniform shape documented in API error and status reference.

The backstop test

tests/api-route-auth-guard.vitest.ts recursively collects every route.ts under src/app/api, asserts there are more than 50 of them so a glob regression cannot make the check vacuous, and requires each file to either match a recognised gate pattern or appear in the allowlist.

Recognised gate tokens (api-route-auth-guard.vitest.ts:23-43):

PatternWhat it covers
auth(), getResolvedClerkSessionMetadataClerk session
isAuthorizedCronRequestCron bearer token
authorize\w+Access, authorizeWordPressRequestPlan and role helpers in src/lib/api/integrations-access.ts
resolveApiKeyAccess, revokeApiKeyByRawKey, x-ermis-api-keyTenant API key
consumeWordPressConnectSessionOne-time WordPress connect session
verifyHumanRequestBotID
requireAdminSession, requireSuperAdminAdmin session helpers
validateEvent, verifyWebhook, constructEvent, verifyResendWebhook, Webhook(Inbound webhook signatures
verifySvixSignature, RESEND_WEBHOOK_SECRETResend's hand-rolled Svix verification
createRssConsumer, rssQueueTopicsQueue message HMAC
handlePublicCmsStoryExportRequestShared CMS export handler

The allowlist has exactly two entries, each self-justifying in the file:

  • src/app/api/health/route.ts — liveness and readiness probe, reports only dependency health.
  • src/app/api/openapi/public-integrations/route.ts — static public OpenAPI document, no tenant data.

The test is textual. It proves a gate token appears somewhere in the file, not that the gate is applied to every exported verb or applied correctly. Behavioural role enforcement is pinned separately in tests/route-audit-regressions.vitest.ts — for example, ops gets 403 on /api/admin/review, editor gets 403 on /api/admin/deliveries, and member gets 403 on /api/team, /api/team/invite, /api/billing/subscription, /api/usage, and /api/integrations/api-keys.

Non-session gates

Some routes cannot use a Clerk session, so they authenticate the request itself.

Cron. The five scheduled endpoints authenticate GET with the cron bearer and POST with an admin Clerk session — the same route serves both. isAuthorizedCronRequest (src/lib/api/cron-auth.ts:46-65) does a constant-time timingSafeEqual against Bearer ${CRON_SECRET} and returns false when CRON_SECRET is unset, so a missing environment variable can never open the endpoint. It can silently stop all scheduled work instead. See Scheduled jobs and the queue subsystem.

Queues. @vercel/queue's handleCallback performs no inbound verification, so the payload's own ERMIS_QUEUE_SIGNING_SECRET HMAC is the only thing between an anonymous POST and the handler.

Webhooks. /api/webhooks/clerk, /api/webhooks/resend, and /api/billing/webhooks/polar verify signatures inside the handler and return 503 when their secret is unset. See Inbound webhooks: Clerk, Polar and Resend.

Tenant API keys. The x-ermis-api-key header authenticates /api/integrations/cms/stories, /api/integrations/wordpress/stories, and /api/integrations/wordpress/disconnect. See Public integration API.

Next.js 16 conventions in this tree

  • params and searchParams are Promises. Pages: params: Promise<{ storyId: string }>. Route handlers: export async function GET(_request: Request, context: { params: Promise<{ storyId: string }> }).
  • Middleware is src/proxy.ts, not middleware.ts.
  • instrumentation.ts (Node and Edge) and instrumentation-client.ts (browser) are separate files.
  • global-error.tsx receives unstable_retry, not reset (src/app/global-error.tsx:8-14). The nested error.tsx boundaries still use reset.
  • typedRoutes: true, reactCompiler: true, pageExtensions: ['tsx','ts','jsx','js'] (next.config.ts:98-110).
  • Turbopack is the bundler, with experimental.turbopackRustReactCompiler: true.
  • experimental.authInterrupts, viewTransition, taint, typedEnv, useTypeScriptCli are all on.
  • experimental.globalNotFound: false — the Next 16 global-not-found convention is not in use. Unknown paths inside a valid locale hit the [...rest] catch-all which calls notFound(); an invalid locale prefix falls to the un-localized root not-found.tsx.

typescript.ignoreBuildErrors: true is deliberate (next.config.ts:112-119). Next's build-time type check goes through typescript.createProgram, which the TypeScript 7 native compiler does not expose until 7.1. next build performs no type checking at allpnpm typecheck in CI is the only thing that does. See Scripts, quality gates and CI.

Traps

buildUnauthenticatedUrl always sends users to /en/sign-in. It uses routing.defaultLocale regardless of the visitor's locale (src/proxy.ts:195-197). A Greek visitor bounced from a protected route lands on the English sign-in page.

/.well-known/vercel/flags falls through to auth.protect. It matches isNonLocalizedRoute (/.well-known(/.*)?) but is not in isPublicRoute, so by the branch order in src/proxy.ts:301-333 the middleware requires a Clerk session before the handler's FLAGS_SECRET signature check ever runs. This is code-evident, not runtime-verified.

/admin/clusters/[clusterId] is reachable only by direct URL. The clusters surface is in the access map and the page is gated, but the admin layout's navItems array omits it and there is no clusters/page.tsx.

/admin/review/[storyId] requires a ?tenantScopeId= query parameter. Story ids are content-derived and can collide across tenants, so without the parameter the page renders a "Missing tenant scope" card instead of the story.

AppTelemetryProvider is mounted only in the tenant layout. Admin and marketing client requests carry no X-Ermis-Source or X-Ermis-Trace, so server logs fall back to the OpenTelemetry trace id.

BotID's client and server lists disagree. initBotId({ protect: [...] }) in src/instrumentation-client.ts lists /api/stories GET and /api/stories/queue POST, but verifyHumanRequest is never called in those handlers. Do not describe those endpoints as bot-protected.

Unmigrated or unreachable Postgres 503s every authenticated surface. There is no in-memory fallback in production. /api/health probes the platform_state table specifically and returns 503 with a detail beginning platform_state unreachable (run db:migrate before serving traffic) (src/app/api/health/route.ts:70). Redis being down is reported but does not fail readiness.

Some tenant strings bypass i18n. The settings billing-warning alert, the editorial-review "Submitted review item not found." card, and the admin missing-tenant-scope card are hardcoded English inside otherwise translated surfaces.

Nesta página