* feat(white-label): brand and team-kind foundation
- brands table: one white-label identity per byra team (unique mutable
domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
chrome tone and WCAG contrast gate; no brand row = default appearance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-request brand theming, wordmark slot and source footer
- root layout resolves the brand from the Host header and injects a
server-rendered style block (light + dark), font pair classes and a
BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
for non-default entries)
- AGPL source-code footer link on login and public pages, both brands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra team invites, member management and team billing
- team invites unfrozen behind a kind gate (byra teams only, owner/admin
invite); members route handles multi-team membership; members/[id]
unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding
- every outbound mail is sent in the brand of the company it concerns:
getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
"via Accounted" fallback, canonical default) wired into invites,
payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra cockpit, home-domain rule and tab guard
- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
next deadline via the status engine, last booked) for byra team members,
who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
signpost page for companies homed elsewhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware app name across UI strings
- 24 message keys per locale converted to the {appName} ICU parameter,
27 call sites pass the active brand name (useBranding client-side,
getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
guard, signpost and source footer (sv/en parity verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(white-label): domain glossary and decision log entries
- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): lean byra cockpit sidebar with company-mode back link
Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings
In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): keep company picker in cockpit with nothing selected
The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(db): renumber white-label migrations past main and add byra settings scope
Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.
Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): cross-client nyckeltal view in the cockpit
Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra self-service brand logo and app name
New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): route root layout through the shared brand resolver
app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): automations roadmap teaser and cockpit i18n strings
The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company
After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.
The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(white-label): drop brand color theming, keep monochrome everywhere
White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook
ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(byra): drop the 'what's coming' tail from the automations intro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins with zero companies land in the empty cockpit
Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): auth wordmark shows the brand logo alone
Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-brand favicon via brands.favicon_url
Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): wire the villkor and integritetspolicy footer links
Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popup for the team role dropdowns
The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded sender shows the brand name alone, no via-platform
Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): byra landing applies to every team member, not only owners/admins
An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded team invite names the byra, not "ett team pa <platform>"
Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar keeps cockpit mode after refresh on settings
The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): hide the active-company chip in byra-scoped settings
The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): tab guard no longer fires in the tab that initiated the switch
BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popups for every settings dropdown
SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): home-domain affinity redirect in middleware
Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): render hero brand logo at 64px on auth pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): shareable invite link and re-send for byra team invites
A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:
- persistent share-link line after invite create/re-send: ochre attn line
with a copy action when the mail did not go out, quiet muted line with
the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
by create and re-send so the two paths cannot drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label
Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): close the four skeptic refutations before merge
- trial seed: migration 130300 now carries the seven-key PAID body from
20260818170000 plus the byra guard, instead of silently reverting it;
pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
create_company_for_user (v1 API + MCP path), and both surfaces resolve
the default team personal-only, so a consultant's private company can
never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
longer redirected off the platform host; the signpost handles per-company
homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
(ROT/RUT work-type picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect
The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1049 lines
40 KiB
TypeScript
1049 lines
40 KiB
TypeScript
import { createServerClient } from '@supabase/ssr'
|
|
import { NextResponse, type NextRequest } from 'next/server'
|
|
import { createLogger } from '@/lib/logger'
|
|
import {
|
|
PROXY_TIMING_HEADER,
|
|
classifyProxyRequest,
|
|
createProxyTimings,
|
|
formatProxyServerTiming,
|
|
proxyRouteTemplate,
|
|
timed,
|
|
type ProxyTimings,
|
|
} from '@/lib/supabase/proxy-timing'
|
|
import { shouldEnforceMfa } from '@/lib/auth/mfa'
|
|
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
|
|
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
|
|
import { userHasPassword } from '@/lib/auth/has-password'
|
|
import { safeReturnTo } from '@/lib/auth/safe-return-to'
|
|
import { normalizeHost, resolveBrandByHost } from '@/lib/branding/resolve'
|
|
import {
|
|
apiRequestSkipsSessionTimeout,
|
|
createSessionTimeoutState,
|
|
evaluateSessionTimeout,
|
|
fetchAutoLogoutPreference,
|
|
getSessionTimeoutConfig,
|
|
sessionStateMatchesUser,
|
|
sessionStateNeedsRemint,
|
|
sessionTimeoutClearCookieOptions,
|
|
sessionTimeoutCookieOptions,
|
|
signSessionTimeoutState,
|
|
verifySessionTimeoutState,
|
|
} from '@/lib/auth/session-timeout'
|
|
import {
|
|
isSessionAuthMethod,
|
|
SESSION_AUTH_METHOD_HINT_COOKIE,
|
|
SESSION_TIMEOUT_COOKIE,
|
|
SESSION_TIMEOUT_REASON_HEADER,
|
|
type SessionAuthMethod,
|
|
type SessionTimeoutReason,
|
|
} from '@/lib/auth/session-timeout-shared'
|
|
|
|
const log = createLogger('proxy')
|
|
|
|
/**
|
|
* Host-scoped marker that the signed-in user is on their home domain, so the
|
|
* affinity check below costs zero queries on the hot path. Expiry re-runs the
|
|
* check, which bounds staleness after team-membership changes.
|
|
*/
|
|
const HOME_DOMAIN_OK_COOKIE = 'gnubok-home-ok'
|
|
const HOME_DOMAIN_OK_MAX_AGE = 15 * 60
|
|
|
|
/**
|
|
* Auth proxy entry point. Wraps the real work so every response carries a
|
|
* per-phase timing header and emits one structured log line, mirroring what
|
|
* withRouteContext does for API routes: without it the proxy's sequential
|
|
* network calls (getUser, session state, company RPC, MFA lookups) were the
|
|
* one part of a request nobody could measure. Page/RSC/prefetch responses
|
|
* get `Server-Timing` (visible in the browser Timing tab); /api responses
|
|
* get `X-Proxy-Timing` so the route wrapper's own Server-Timing is left
|
|
* alone. Token-carrying paths are collapsed before logging.
|
|
*/
|
|
export async function updateSession(request: NextRequest) {
|
|
const start = Date.now()
|
|
const timing = createProxyTimings()
|
|
const response = await updateSessionInner(request, timing)
|
|
const totalMs = Date.now() - start
|
|
const pathname = request.nextUrl.pathname
|
|
const kind = classifyProxyRequest(pathname, request.headers)
|
|
response.headers.set(
|
|
kind === 'api' ? PROXY_TIMING_HEADER : 'Server-Timing',
|
|
formatProxyServerTiming(timing, totalMs),
|
|
)
|
|
log.info('proxy completed', {
|
|
kind,
|
|
route: proxyRouteTemplate(pathname),
|
|
status: response.status,
|
|
...timing,
|
|
totalMs,
|
|
})
|
|
return response
|
|
}
|
|
|
|
async function updateSessionInner(
|
|
request: NextRequest,
|
|
timing: ProxyTimings,
|
|
): Promise<NextResponse> {
|
|
let supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
|
|
const supabase = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
cookiesToSet.forEach(({ name, value }) =>
|
|
request.cookies.set(name, value)
|
|
)
|
|
supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
supabaseResponse.cookies.set(name, value, options)
|
|
)
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
// IMPORTANT: Avoid writing any logic between createServerClient and
|
|
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
|
|
// issues with users being randomly logged out.
|
|
|
|
const {
|
|
data: { user },
|
|
error: authError,
|
|
} = await timed(timing, 'authMs', () => supabase.auth.getUser())
|
|
|
|
// Get the pathname
|
|
const pathname = request.nextUrl.pathname
|
|
|
|
// If the refresh token is stale/invalid, clear the session cookies so the
|
|
// browser stops sending them on every request, INCLUDING /api requests,
|
|
// which previously returned before this cleanup and replayed the dead
|
|
// token forever. Skip on auth routes, the callback needs PKCE cookies
|
|
// intact. scope: 'local' only clears cookies: the refresh token is already
|
|
// dead server-side, and the default global-revoke round-trip re-triggers
|
|
// the failed refresh, the exact AuthApiError this cleans up after.
|
|
if (authError && !user && !pathname.startsWith('/auth')) {
|
|
try {
|
|
await supabase.auth.signOut({ scope: 'local' })
|
|
} catch (signOutError) {
|
|
// Expected session expiry, not a runtime error.
|
|
console.warn('[middleware] session cleanup after stale refresh token failed', signOutError)
|
|
}
|
|
}
|
|
|
|
const timeoutConfig = getSessionTimeoutConfig()
|
|
const hasAuthorizationHeader = request.headers.get('authorization') !== null
|
|
|
|
if (!user) {
|
|
clearSessionTimeoutCookies(request, supabaseResponse)
|
|
} else if (
|
|
timeoutConfig.enabled &&
|
|
!apiRequestSkipsSessionTimeout(pathname, hasAuthorizationHeader)
|
|
) {
|
|
const encodedState = request.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
|
|
const sessionId = await timed(timing, 'sessionMs', () =>
|
|
getSupabaseSessionId(supabase),
|
|
)
|
|
const verifiedState = await timed(timing, 'sessionMs', () =>
|
|
verifySessionTimeoutState(encodedState),
|
|
)
|
|
|
|
if (encodedState && !verifiedState) {
|
|
await signOutTimedOutSession(supabase)
|
|
return sessionTimeoutResponse(
|
|
request,
|
|
supabaseResponse,
|
|
'absolute',
|
|
'password',
|
|
)
|
|
}
|
|
|
|
const stateMatches =
|
|
verifiedState !== null &&
|
|
sessionStateMatchesUser(verifiedState, user.id, sessionId)
|
|
|
|
if (
|
|
!verifiedState ||
|
|
!stateMatches ||
|
|
sessionStateNeedsRemint(verifiedState)
|
|
) {
|
|
const hintedMethod = request.cookies.get(
|
|
SESSION_AUTH_METHOD_HINT_COOKIE,
|
|
)?.value
|
|
const method = isSessionAuthMethod(hintedMethod)
|
|
? hintedMethod
|
|
: 'password'
|
|
const autoLogout = await timed(timing, 'sessionMs', () =>
|
|
fetchAutoLogoutPreference(supabase, user.id),
|
|
)
|
|
|
|
// Unknown preference (failed read): mint nothing, so no fail-open
|
|
// snapshot gets persisted; the next request retries the read.
|
|
if (autoLogout !== null) {
|
|
// A matching pre-toggle cookie keeps its timers: upgrading the shape
|
|
// must not restart the absolute window.
|
|
const state = verifiedState && stateMatches
|
|
? { ...verifiedState, autoLogout }
|
|
: createSessionTimeoutState({
|
|
userId: user.id,
|
|
sessionId,
|
|
method,
|
|
autoLogout,
|
|
})
|
|
const signedState = await signSessionTimeoutState(state)
|
|
|
|
if (signedState) {
|
|
request.cookies.set(SESSION_TIMEOUT_COOKIE, signedState)
|
|
supabaseResponse.cookies.set(
|
|
SESSION_TIMEOUT_COOKIE,
|
|
signedState,
|
|
sessionTimeoutCookieOptions(),
|
|
)
|
|
clearAuthMethodHint(request, supabaseResponse)
|
|
}
|
|
}
|
|
} else {
|
|
const timeoutReason = evaluateSessionTimeout(
|
|
verifiedState,
|
|
timeoutConfig,
|
|
)
|
|
if (timeoutReason) {
|
|
await signOutTimedOutSession(supabase)
|
|
return sessionTimeoutResponse(
|
|
request,
|
|
supabaseResponse,
|
|
timeoutReason,
|
|
verifiedState.method,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── API routes ──────────────────────────────────────────────────────────
|
|
// API routes authenticate themselves (requireAuth, API-key Bearer, cron
|
|
// secret, webhook signatures). Middleware runs on them for ONE reason: to
|
|
// close the MFA gap. Many legacy routes hand-roll supabase.auth.getUser()
|
|
// instead of requireAuth(), so without this an authenticated-but-not-MFA-
|
|
// verified (AAL1) cookie session could reach them on the hosted product.
|
|
// Gate ONLY cookie sessions. Bearer-auth SURFACES (/api/v1, the MCP
|
|
// endpoint) and the AAL1 escape-hatch / OAuth routes pass straight through
|
|
// (see apiPathSkipsMfaGate): header presence alone never skips the gate,
|
|
// since the header is attacker-controlled and cookie-authenticated routes
|
|
// ignore it. Pure Bearer callers (cron, webhooks) carry no cookie session,
|
|
// so the `user` guard below already excludes them. Everything else about
|
|
// /api auth stays the route's own responsibility.
|
|
if (pathname.startsWith('/api')) {
|
|
const skipMfaGate = apiPathSkipsMfaGate(
|
|
pathname,
|
|
hasAuthorizationHeader,
|
|
)
|
|
if (!skipMfaGate && user && shouldEnforceMfa(user)) {
|
|
const { data: aal } = await timed(timing, 'mfaMs', () =>
|
|
supabase.auth.mfa.getAuthenticatorAssuranceLevel(),
|
|
)
|
|
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
|
|
return NextResponse.json({ error: 'MFA-verifiering krävs.' }, { status: 403 })
|
|
}
|
|
}
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Invite pages: accessible to everyone, signed in or not. A user who
|
|
// already has an account and is signed in should still be able to land on
|
|
// /invite/[token] to accept the invite with one click (see
|
|
// app/invite/[token]/page.tsx). If we bounce them to '/', they never see
|
|
// the invite at all.
|
|
if (pathname.startsWith('/invite')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Public payslip pages, the token in the URL is the authentication
|
|
// (resolved server-side against salary_payslip_links). Employees have no
|
|
// account; bouncing them to /login would make every emailed payslip link
|
|
// dead. See app/payslip/[token]/page.tsx.
|
|
if (pathname.startsWith('/payslip')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Reset-password is reachable in both auth states. The recovery flow lands
|
|
// here with a fresh session (created by the OTP exchange in /auth/callback)
|
|
// precisely so the user can call supabase.auth.updateUser({ password }). If
|
|
// we bounce authenticated users to '/', the recovery email link silently
|
|
// fails. An already-logged-in user typing /reset-password directly just gets
|
|
// the same "change password" experience as in settings: no security loss.
|
|
if (pathname.startsWith('/reset-password')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Public agent-discovery + API docs surfaces. /llms.txt and /llms-full.txt
|
|
// exist FOR anonymous consumers (the llms.txt convention targets logged-out
|
|
// crawlers and IDE agents), and /docs is the public API documentation the
|
|
// OpenAPI spec and the installable accounted-api skill link to. None of it
|
|
// reads the session. Without this branch every anonymous hit 307-bounced to
|
|
// /login, which silently broke agent discovery on the hosted product
|
|
// (openapi.json only escaped because the proxy matcher skips .json paths).
|
|
// Logged-in users fall through to the same content: no redirect either way.
|
|
if (
|
|
pathname === '/llms.txt' ||
|
|
pathname === '/llms-full.txt' ||
|
|
pathname === '/docs' ||
|
|
pathname.startsWith('/docs/')
|
|
) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Public auth routes: allow access
|
|
if (
|
|
pathname.startsWith('/login') ||
|
|
pathname.startsWith('/register') ||
|
|
pathname.startsWith('/auth') ||
|
|
pathname.startsWith('/sandbox')
|
|
) {
|
|
// If user is logged in and trying to access auth pages, redirect to the
|
|
// destination the auth page would have sent them to, dashboard otherwise.
|
|
// /login?next=… is set by callers like the MCP OAuth authorize endpoint
|
|
// and by the bounce below; discarding the whole query string here
|
|
// stranded an already-signed-in user on the dashboard instead of the
|
|
// deep link they clicked. Only /login and /register carry `next`;
|
|
// /auth (the PKCE callback) and /sandbox bounce to '/' exactly as before.
|
|
if (user) {
|
|
const carriesDestination =
|
|
pathname.startsWith('/login') || pathname.startsWith('/register')
|
|
const destination = carriesDestination
|
|
? safeReturnTo(request.nextUrl.searchParams.get('next'), '/')
|
|
: '/'
|
|
return NextResponse.redirect(new URL(destination, request.url))
|
|
}
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Protected routes - require authentication
|
|
if (!user) {
|
|
return bounceToAuth(request, '/login')
|
|
}
|
|
|
|
// ── Home-domain affinity (WL, founder call 2026-08-05) ──────────────────
|
|
// Every signed-in user has a home domain: byrå team members home on their
|
|
// brand's domain, everyone else on the platform app URL, except a byrå's
|
|
// client users, whose home is the byrå domain their companies live under.
|
|
// On a mismatch the request is redirected to the home domain's root:
|
|
// sessions are per domain, so the user lands on the RIGHT branded login
|
|
// and signs in there ("the domain corrects itself"). This complements the
|
|
// WL-01 signpost, which handles per-company homing INSIDE a domain and
|
|
// stays the answer for multi-domain company rosters. Exemption: byrå
|
|
// staff who also have canonical-homed companies stay put on the canonical
|
|
// host; the signpost handles per-company homing.
|
|
const homeOutcome = await resolveHomeDomainOutcome(supabase, user.id, request)
|
|
if (homeOutcome.redirectTo) {
|
|
return NextResponse.redirect(homeOutcome.redirectTo)
|
|
}
|
|
if (homeOutcome.cacheOk) {
|
|
supabaseResponse.cookies.set(
|
|
HOME_DOMAIN_OK_COOKIE,
|
|
normalizeHost(request.nextUrl.hostname),
|
|
{
|
|
path: '/',
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: HOME_DOMAIN_OK_MAX_AGE,
|
|
},
|
|
)
|
|
}
|
|
|
|
// /mfa/enroll: gate behind has-password. BankID-only users who reach this
|
|
// page can lock themselves out: Supabase requires AAL2 to change password
|
|
// or unenroll MFA, and AAL2 needs a prior password sign-in. Force them to
|
|
// set a password first. The /account/set-password page does that and routes
|
|
// back here via ?returnTo. Thread the inner returnTo through so the user
|
|
// ends up on their original destination after the full chain completes.
|
|
if (pathname.startsWith('/mfa/enroll')) {
|
|
if (!userHasPassword(user)) {
|
|
const innerReturnTo = request.nextUrl.searchParams.get('returnTo')
|
|
const mfaTarget = `/mfa/enroll${
|
|
innerReturnTo ? `?returnTo=${encodeURIComponent(innerReturnTo)}` : ''
|
|
}`
|
|
return NextResponse.redirect(
|
|
new URL(
|
|
`/account/set-password?returnTo=${encodeURIComponent(mfaTarget)}`,
|
|
request.url,
|
|
),
|
|
)
|
|
}
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Other MFA pages: accessible to authenticated users (AAL1+), skip MFA enforcement
|
|
if (pathname.startsWith('/mfa/')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// /account/set-password is the escape hatch from the BankID/MFA lockout
|
|
// and must be reachable even when the user has no company yet (e.g. mid-
|
|
// onboarding) and is at AAL1.
|
|
if (pathname.startsWith('/account/set-password')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Resolve the active company at most once per request: both the MFA
|
|
// enrollment gate and the company-context block below need it, and the
|
|
// resolution costs DB round trips.
|
|
let resolvedCompany: {
|
|
companyId: string | null
|
|
locale: string | null
|
|
degraded: boolean
|
|
} | null = null
|
|
const resolveCompanyOnce = async () =>
|
|
(resolvedCompany ??= await timed(timing, 'companyMs', () =>
|
|
resolveCompanyForMiddleware(supabase, user.id, request),
|
|
))
|
|
|
|
// MFA enforcement (application-side only, not RLS)
|
|
if (shouldEnforceMfa(user)) {
|
|
const { data: aal } = await timed(timing, 'mfaMs', () =>
|
|
supabase.auth.mfa.getAuthenticatorAssuranceLevel(),
|
|
)
|
|
|
|
// User has MFA enrolled but hasn't verified this session → redirect to verify
|
|
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
|
|
return bounceToAuth(request, '/mfa/verify')
|
|
}
|
|
|
|
// MFA required but user has no factor enrolled yet → force enrollment
|
|
// Skip for users with no companies (still setting up).
|
|
//
|
|
// Only worth asking when the session is NOT at AAL2: reaching AAL2
|
|
// requires having verified a challenge on a verified factor, so the
|
|
// factor list cannot be empty there. auth-js implements listFactors()
|
|
// as a getUser() network round trip, and running it here on every
|
|
// page, RSC and prefetch request for every MFA-verified user was the
|
|
// second Supabase Auth call per request (measured via mw-mfa, PR #1922).
|
|
// The narrow case this defers is a user who unenrols their last factor
|
|
// mid-session: the JWT keeps aal2 until the next token refresh, so the
|
|
// enrolment bounce lands on the refresh instead of the next click.
|
|
if (aal?.currentLevel !== 'aal2') {
|
|
const { companyId: companyIdForMfa } = await resolveCompanyOnce()
|
|
if (companyIdForMfa) {
|
|
const { data: factors } = await timed(timing, 'mfaMs', () =>
|
|
supabase.auth.mfa.listFactors(),
|
|
)
|
|
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
|
|
|
|
if (!hasVerifiedFactor) {
|
|
return bounceToAuth(request, '/mfa/enroll')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Forward the pathname so server layouts can branch on it (e.g. render a
|
|
// no-company shell for /settings/account).
|
|
supabaseResponse.headers.set('x-pathname', pathname)
|
|
|
|
// Company context resolution
|
|
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
|
|
const { companyId, locale: dbLocale, degraded } = await resolveCompanyOnce()
|
|
|
|
// If the cookie pointed at a company we can no longer resolve (e.g.
|
|
// archived), clear it so the browser stops sending it. Never on degraded
|
|
// resolution: a transient query failure must not wipe a valid cookie.
|
|
if (!degraded && cookieCompanyId && cookieCompanyId !== companyId) {
|
|
supabaseResponse.cookies.set('gnubok-company-id', '', { path: '/', maxAge: 0 })
|
|
}
|
|
|
|
// Sync the locale cookie from user_preferences. This keeps next-intl's
|
|
// request config (which reads the cookie) consistent with the DB value
|
|
// without forcing every RSC render to query the database itself.
|
|
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value
|
|
const effectiveLocale = isLocale(dbLocale) ? dbLocale : DEFAULT_LOCALE
|
|
if (!degraded && cookieLocale !== effectiveLocale) {
|
|
supabaseResponse.cookies.set(LOCALE_COOKIE, effectiveLocale, {
|
|
path: '/',
|
|
sameSite: 'lax',
|
|
secure: process.env.NODE_ENV === 'production',
|
|
maxAge: 60 * 60 * 24 * 365,
|
|
})
|
|
}
|
|
|
|
// Routes that stay accessible when the user has no active company.
|
|
// Needed so a user who archived their last company can still delete
|
|
// their account without being trapped on /onboarding forever.
|
|
const isNoCompanyAllowed =
|
|
pathname.startsWith('/onboarding') ||
|
|
pathname.startsWith('/select-company') ||
|
|
pathname.startsWith('/settings/account') ||
|
|
pathname.startsWith('/api/account/') ||
|
|
pathname.startsWith('/api/company')
|
|
|
|
// No companies: redirect to the picker if we have BankID enrichment for
|
|
// this user, otherwise the manual wizard. Either way, allow the escape-hatch
|
|
// routes to pass through.
|
|
if (!companyId) {
|
|
if (isNoCompanyAllowed) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Degraded resolution (a query FAILED, as opposed to returning no rows)
|
|
// means the user's companies are unknown, not absent. Fail open: pass
|
|
// the request through and let the layout's own resolution retry or
|
|
// surface an error. Redirecting here showed fully onboarded users the
|
|
// onboarding wizard again on a transient failure (issue #1053).
|
|
if (degraded) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Byrå team members (any role: widened from owner/admin, founder call
|
|
// 2026-08-05) with zero client companies (a fresh byrå) home to the
|
|
// EMPTY cockpit, never to the company onboarding wizard: clients are
|
|
// created from the cockpit, and forcing the wizard here would make a
|
|
// byrå user create a personal company just to get in. Cockpit-shaped
|
|
// paths pass through (the dashboard layout renders its no-company shell);
|
|
// everything else is steered to /byra. API requests pass through so the
|
|
// routes' own guards answer with JSON instead of an HTML redirect.
|
|
// The query runs only in the rare no-company state: zero hot-path cost.
|
|
const { data: byraRows } = await supabase
|
|
.from('team_members')
|
|
.select('role, teams:team_id!inner(kind)')
|
|
.eq('user_id', user.id)
|
|
.eq('teams.kind', 'byra')
|
|
const isByraMember = (byraRows ?? []).length > 0
|
|
if (isByraMember) {
|
|
const isByraNoCompanyAllowed =
|
|
pathname.startsWith('/byra') ||
|
|
pathname.startsWith('/clients') ||
|
|
pathname.startsWith('/companies/new') ||
|
|
pathname.startsWith('/settings') ||
|
|
pathname.startsWith('/api/')
|
|
if (isByraNoCompanyAllowed) {
|
|
return supabaseResponse
|
|
}
|
|
return NextResponse.redirect(new URL('/byra', request.url))
|
|
}
|
|
|
|
// Enrichment lives in the user-keyed `bankid_enrichment` table (migration
|
|
// 20260506160000), it cannot live in extension_data, which is
|
|
// company-scoped, and the user has no company yet on this path.
|
|
const { data: enrichmentRow } = await supabase
|
|
.from('bankid_enrichment')
|
|
.select('user_id')
|
|
.eq('user_id', user.id)
|
|
.maybeSingle()
|
|
|
|
const destination = enrichmentRow ? '/select-company' : '/onboarding'
|
|
return NextResponse.redirect(new URL(destination, request.url))
|
|
}
|
|
|
|
// Set company cookie on the response so downstream requests have it
|
|
supabaseResponse.cookies.set('gnubok-company-id', companyId, {
|
|
path: '/',
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24 * 365,
|
|
})
|
|
|
|
// Allow access to onboarding (for adding new companies), select-company, and companies/new
|
|
if (pathname.startsWith('/select-company') || pathname.startsWith('/companies/new') || pathname.startsWith('/onboarding')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
return supabaseResponse
|
|
}
|
|
|
|
async function getSupabaseSessionId(
|
|
supabase: ReturnType<typeof createServerClient>,
|
|
): Promise<string | null> {
|
|
if (typeof supabase.auth.getClaims !== 'function') return null
|
|
|
|
try {
|
|
const { data } = await supabase.auth.getClaims()
|
|
return typeof data?.claims?.session_id === 'string'
|
|
? data.claims.session_id
|
|
: null
|
|
} catch (error) {
|
|
console.warn('[middleware] could not resolve Supabase session id', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function signOutTimedOutSession(
|
|
supabase: ReturnType<typeof createServerClient>,
|
|
): Promise<void> {
|
|
try {
|
|
await supabase.auth.signOut({ scope: 'local' })
|
|
} catch (error) {
|
|
console.warn('[middleware] timed-out session revocation failed', error)
|
|
}
|
|
}
|
|
|
|
function clearAuthMethodHint(
|
|
request: NextRequest,
|
|
response: NextResponse,
|
|
): void {
|
|
if (!request.cookies.has(SESSION_AUTH_METHOD_HINT_COOKIE)) return
|
|
request.cookies.delete(SESSION_AUTH_METHOD_HINT_COOKIE)
|
|
response.cookies.set(SESSION_AUTH_METHOD_HINT_COOKIE, '', {
|
|
path: '/',
|
|
maxAge: 0,
|
|
sameSite: 'lax',
|
|
secure: process.env.NODE_ENV === 'production',
|
|
})
|
|
}
|
|
|
|
function clearSessionTimeoutCookies(
|
|
request: NextRequest,
|
|
response: NextResponse,
|
|
): void {
|
|
if (request.cookies.has(SESSION_TIMEOUT_COOKIE)) {
|
|
request.cookies.delete(SESSION_TIMEOUT_COOKIE)
|
|
response.cookies.set(
|
|
SESSION_TIMEOUT_COOKIE,
|
|
'',
|
|
sessionTimeoutClearCookieOptions(),
|
|
)
|
|
}
|
|
clearAuthMethodHint(request, response)
|
|
}
|
|
|
|
function copyResponseCookies(from: NextResponse, to: NextResponse): void {
|
|
for (const cookie of from.cookies.getAll()) {
|
|
to.cookies.set(cookie)
|
|
}
|
|
}
|
|
|
|
function sessionTimeoutResponse(
|
|
request: NextRequest,
|
|
authResponse: NextResponse,
|
|
reason: SessionTimeoutReason,
|
|
method: SessionAuthMethod,
|
|
): NextResponse {
|
|
clearSessionTimeoutCookies(request, authResponse)
|
|
|
|
if (request.nextUrl.pathname.startsWith('/api')) {
|
|
const response = NextResponse.json(
|
|
{
|
|
error: {
|
|
code: 'SESSION_EXPIRED',
|
|
message: reason === 'idle'
|
|
? 'Sessionen har upphört på grund av inaktivitet.'
|
|
: 'Sessionen har upphört av säkerhetsskäl.',
|
|
message_en: reason === 'idle'
|
|
? 'The session expired due to inactivity.'
|
|
: 'The session expired for security reasons.',
|
|
reason,
|
|
},
|
|
},
|
|
{ status: 401 },
|
|
)
|
|
response.headers.set(SESSION_TIMEOUT_REASON_HEADER, reason)
|
|
response.headers.set('Cache-Control', 'no-store')
|
|
copyResponseCookies(authResponse, response)
|
|
return response
|
|
}
|
|
|
|
const url = new URL('/login', request.url)
|
|
url.searchParams.set('reason', reason)
|
|
url.searchParams.set('method', method)
|
|
const destination = safeReturnTo(
|
|
request.nextUrl.pathname + request.nextUrl.search,
|
|
'/',
|
|
)
|
|
if (destination !== '/') url.searchParams.set('next', destination)
|
|
|
|
const response = NextResponse.redirect(url)
|
|
response.headers.set('Cache-Control', 'no-store')
|
|
copyResponseCookies(authResponse, response)
|
|
return response
|
|
}
|
|
|
|
/**
|
|
* Which query parameter each auth page reads its post-auth destination from.
|
|
* /login reads `next` (app/(auth)/login/page.tsx), the MFA pages read
|
|
* `returnTo` (app/(auth)/mfa/verify/page.tsx, app/(auth)/mfa/enroll/page.tsx).
|
|
* Sending the wrong name is a silent no-op, so the mapping is explicit
|
|
* rather than guessed per call site.
|
|
*/
|
|
const AUTH_DESTINATION_PARAM = {
|
|
'/login': 'next',
|
|
'/mfa/verify': 'returnTo',
|
|
'/mfa/enroll': 'returnTo',
|
|
} as const
|
|
|
|
/**
|
|
* Bounce to an auth page, remembering where the user was heading.
|
|
*
|
|
* Fixes two things the hand-rolled redirects did wrong. (1) Cloning
|
|
* `request.nextUrl` and overwriting only `pathname` carried the ORIGINAL
|
|
* query string onto the auth page: /settings/billing?success=1 arrived as
|
|
* /login?success=1, a stray parameter the login page never asked for. The
|
|
* URL here is built fresh from the request origin, so it holds nothing but
|
|
* the one parameter we set. (2) The destination itself was dropped, so
|
|
* emailed deep links and payment returns landed on the dashboard after
|
|
* sign-in instead of where the user was going.
|
|
*
|
|
* Open-redirect guard: the destination is the CURRENT request's path plus
|
|
* query, run through `safeReturnTo`, which admits same-origin relative paths
|
|
* only. Absolute URLs, protocol-relative `//evil.com`, and the encoded forms
|
|
* that normalise into one are rejected, and a rejected (or absent, or
|
|
* root) destination degrades to a bare bounce with no parameter at all.
|
|
* Nothing attacker-supplied is reflected unvalidated.
|
|
*
|
|
* MFA semantics are untouched: this only decorates the URL of a redirect
|
|
* that was going to happen anyway, on exactly the same conditions. The auth
|
|
* pages navigate to the destination only after the step-up succeeds, and the
|
|
* next request re-runs this same gate regardless.
|
|
*/
|
|
function bounceToAuth(
|
|
request: NextRequest,
|
|
target: keyof typeof AUTH_DESTINATION_PARAM,
|
|
) {
|
|
// Absolute-path reference: replaces path AND clears query/fragment.
|
|
const url = new URL(target, request.url)
|
|
const destination = safeReturnTo(
|
|
request.nextUrl.pathname + request.nextUrl.search,
|
|
'/',
|
|
)
|
|
if (destination !== '/') {
|
|
url.search = `${AUTH_DESTINATION_PARAM[target]}=${encodeURIComponent(destination)}`
|
|
}
|
|
return NextResponse.redirect(url)
|
|
}
|
|
|
|
/**
|
|
* Hosts that carry no brand affinity: local dev and direct Vercel
|
|
* deployment URLs must never bounce a signed-in user to a product domain.
|
|
*/
|
|
function isAffinityExemptHost(host: string): boolean {
|
|
return (
|
|
!host ||
|
|
host === 'localhost' ||
|
|
host.endsWith('.localhost') ||
|
|
host.endsWith('.vercel.app') ||
|
|
/^\d{1,3}(\.\d{1,3}){3}$/.test(host)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Decide whether this signed-in request sits on the user's home domain.
|
|
*
|
|
* Rules (founder call 2026-08-05):
|
|
* 1. A byrå team member's home is their brand's domain: any other product
|
|
* host (a foreign byrå domain OR the platform domain) redirects there,
|
|
* EXCEPT on the canonical platform host when the member also has a
|
|
* company homed there (a company whose team has no brand): they stay,
|
|
* and the WL-01 signpost handles per-company homing.
|
|
* 2. A non-member on a brand domain redirects to the platform app URL,
|
|
* UNLESS one of their companies is homed under that brand's team (the
|
|
* byrå's own client users log in on the byrå domain).
|
|
* 3. Everyone else stays put.
|
|
*
|
|
* `cacheOk` marks a positive "this is home" verdict, cached in a host-scoped
|
|
* cookie by the caller. Query failures fail open with no caching, so a
|
|
* transient error neither locks anyone out nor sticks for a TTL window.
|
|
*/
|
|
async function resolveHomeDomainOutcome(
|
|
supabase: ReturnType<typeof createServerClient>,
|
|
userId: string,
|
|
request: NextRequest,
|
|
): Promise<{ redirectTo: URL | null; cacheOk: boolean }> {
|
|
const stay = { redirectTo: null, cacheOk: false }
|
|
const host = normalizeHost(request.nextUrl.hostname)
|
|
if (isAffinityExemptHost(host)) return stay
|
|
if (request.cookies.get(HOME_DOMAIN_OK_COOKIE)?.value === host) return stay
|
|
|
|
// Rule 1: the user's own byrå brand domains (RLS: members read their brand).
|
|
const { data: byraRows, error: byraError } = await supabase
|
|
.from('team_members')
|
|
.select('teams:team_id!inner(kind, brands(domain))')
|
|
.eq('user_id', userId)
|
|
.eq('teams.kind', 'byra')
|
|
if (byraError) {
|
|
console.error('[middleware] home-domain byrå lookup failed', byraError)
|
|
return stay
|
|
}
|
|
|
|
const byraDomains: string[] = []
|
|
for (const row of byraRows ?? []) {
|
|
const teams = (row as { teams?: { brands?: unknown } | null }).teams
|
|
const brands = teams?.brands
|
|
// One brand per team (brands.team_id unique): PostgREST returns an
|
|
// object, but tolerate the array shape too.
|
|
const list = Array.isArray(brands) ? brands : brands ? [brands] : []
|
|
for (const entry of list) {
|
|
const domain = (entry as { domain?: unknown }).domain
|
|
if (typeof domain === 'string' && domain) byraDomains.push(normalizeHost(domain))
|
|
}
|
|
}
|
|
if (byraDomains.length > 0) {
|
|
if (byraDomains.includes(host)) return { redirectTo: null, cacheOk: true }
|
|
|
|
// Exemption: a byrå member who ALSO belongs to a company homed on the
|
|
// canonical domain (its team has no brand, or no team at all) must be
|
|
// able to reach that company somewhere, and the canonical host is its
|
|
// only home. Redirecting them off canonical made their own company
|
|
// deterministically unreachable: on the brand host it renders as a
|
|
// non-clickable signpost pointing right back at canonical. So on the
|
|
// canonical host, stay when such a company exists (host-stable verdict,
|
|
// so it is cacheable); a foreign brand host still redirects, since
|
|
// nothing of the user's is homed there. Cost: one extra query, only for
|
|
// byrå members on the canonical host without a fresh home-ok cookie.
|
|
const canonicalHost = normalizeHost(
|
|
new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://app.gnubok.se').hostname,
|
|
)
|
|
if (host === canonicalHost) {
|
|
const { data: companyRows, error: companyError } = await supabase
|
|
.from('company_members')
|
|
.select('companies!inner(team_id, teams(brands(id)))')
|
|
.eq('user_id', userId)
|
|
if (companyError) {
|
|
console.error(
|
|
'[middleware] home-domain canonical-company lookup failed',
|
|
companyError,
|
|
)
|
|
return stay
|
|
}
|
|
if ((companyRows ?? []).some(rowHasCanonicalHomedCompany)) {
|
|
return { redirectTo: null, cacheOk: true }
|
|
}
|
|
}
|
|
// Carry the original path and query across the hop so deep links (invite
|
|
// accepts, direct object URLs) survive the domain correction.
|
|
return {
|
|
redirectTo: new URL(
|
|
`${request.nextUrl.pathname}${request.nextUrl.search}`,
|
|
`https://${byraDomains[0]}`,
|
|
),
|
|
cacheOk: false,
|
|
}
|
|
}
|
|
|
|
// Rule 2: not a byrå member, so only a brand host can be foreign.
|
|
const hostBrand = await resolveBrandByHost(host)
|
|
if (!hostBrand) return { redirectTo: null, cacheOk: true }
|
|
|
|
const { data: clientRows, error: clientError } = await supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(team_id)')
|
|
.eq('user_id', userId)
|
|
.eq('companies.team_id', hostBrand.teamId)
|
|
.limit(1)
|
|
if (clientError) {
|
|
console.error('[middleware] home-domain client lookup failed', clientError)
|
|
return stay
|
|
}
|
|
if ((clientRows ?? []).length > 0) return { redirectTo: null, cacheOk: true }
|
|
|
|
const platformUrl = new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://app.gnubok.se')
|
|
if (normalizeHost(platformUrl.hostname) === host) return { redirectTo: null, cacheOk: true }
|
|
// Preserve path + query for the same deep-link reason as the byrå hop.
|
|
return {
|
|
redirectTo: new URL(
|
|
`${request.nextUrl.pathname}${request.nextUrl.search}`,
|
|
platformUrl.origin,
|
|
),
|
|
cacheOk: false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a company_members row (carrying the companies → teams → brands
|
|
* embed) points at a company homed on the canonical domain: no team at all,
|
|
* or a team without a brands row. The brand null-check happens HERE in JS on
|
|
* purpose: a PostgREST `.is()` filter on an embedded resource does not
|
|
* filter the parent rows, so filtering server-side would silently match
|
|
* every membership. Embeds arrive as object or array depending on the
|
|
* relationship shape, so both are tolerated (same as the byraRows parsing).
|
|
*/
|
|
function rowHasCanonicalHomedCompany(row: unknown): boolean {
|
|
const companies = (row as { companies?: unknown }).companies
|
|
const companyList = Array.isArray(companies) ? companies : companies ? [companies] : []
|
|
for (const company of companyList) {
|
|
const teams = (company as { teams?: unknown }).teams
|
|
if (!teams) return true
|
|
const teamList = Array.isArray(teams) ? teams : [teams]
|
|
for (const team of teamList) {
|
|
const brands = (team as { brands?: unknown }).brands
|
|
const brandList = Array.isArray(brands) ? brands : brands ? [brands] : []
|
|
if (brandList.length === 0) return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Resolve the active company for the authenticated user.
|
|
*
|
|
* Resolution: user_preferences → first non-archived membership.
|
|
*
|
|
* `user_preferences.active_company_id` is the authoritative source for
|
|
* the active company on both the Next.js and Postgres RLS side. The
|
|
* `gnubok-company-id` cookie is still refreshed for legacy read paths
|
|
* but it is no longer READ here, because RLS (via
|
|
* `current_active_company_id()`) cannot see cookies: so letting the
|
|
* cookie override the database would re-introduce the divergence this
|
|
* entire migration exists to fix.
|
|
*
|
|
* When we fall back to "first membership" (no user_preferences row yet),
|
|
* we also upsert user_preferences so subsequent RLS lookups agree with
|
|
* us without needing the fallback scan.
|
|
*
|
|
* RPC-first: `resolve_active_company()` collapses the whole resolution into
|
|
* one round trip and is semantically identical to both the query path below
|
|
* and `current_active_company_id()` (what RLS reads). `used_fallback` is
|
|
* true exactly when the preference was missing, null, or stale, which is
|
|
* exactly the condition under which the query path writes the resolved
|
|
* company back to user_preferences: the write-back behavior is preserved.
|
|
* Falls back to the query path on PGRST202 (self-hosted instance not
|
|
* migrated yet, or a deploy racing the branch merge).
|
|
*
|
|
* Cannot use lib/company/context.ts because middleware runs on Edge.
|
|
*/
|
|
async function resolveCompanyForMiddleware(
|
|
supabase: ReturnType<typeof createServerClient>,
|
|
userId: string,
|
|
_request: NextRequest
|
|
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
|
|
const { data, error } = await supabase.rpc('resolve_active_company')
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST202') {
|
|
// Function not deployed here: use the query path.
|
|
return resolveCompanyForMiddlewareViaQueries(supabase, userId, _request)
|
|
}
|
|
// Issue #1053: a FAILED call degrades (fail open), never reads as "no
|
|
// companies". locale null is fine because the degraded flag already
|
|
// suppresses the locale-cookie sync at the call site.
|
|
console.error('[middleware] resolve_active_company rpc failed', error)
|
|
return { companyId: null, locale: null, degraded: true }
|
|
}
|
|
|
|
const row = Array.isArray(data) ? data[0] : data
|
|
if (!row) {
|
|
// Zero rows = NULL auth.uid(); impossible for the cookie-auth middleware
|
|
// client, so treat as degraded rather than redirecting to onboarding.
|
|
console.error('[middleware] resolve_active_company returned no row for authenticated user')
|
|
return { companyId: null, locale: null, degraded: true }
|
|
}
|
|
|
|
if (row.company_id && row.used_fallback) {
|
|
// Write the fallback back to user_preferences so future RLS lookups see
|
|
// the same active company without needing the fallback scan. Non-fatal
|
|
// on failure: resolution already succeeded, but log it so silent
|
|
// persistence failures (#701) are observable.
|
|
const { error: writeBackError } = await supabase
|
|
.from('user_preferences')
|
|
.upsert(
|
|
{ user_id: userId, active_company_id: row.company_id },
|
|
{ onConflict: 'user_id' }
|
|
)
|
|
if (writeBackError) {
|
|
console.error('[middleware] active company write-back failed', writeBackError)
|
|
}
|
|
}
|
|
|
|
return {
|
|
companyId: row.company_id ?? null,
|
|
locale: row.locale ?? null,
|
|
degraded: false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
|
|
* fallback for resolveCompanyForMiddleware (see the fallback conditions
|
|
* there).
|
|
*/
|
|
async function resolveCompanyForMiddlewareViaQueries(
|
|
supabase: ReturnType<typeof createServerClient>,
|
|
userId: string,
|
|
_request: NextRequest
|
|
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
|
|
// 1. user_preferences (authoritative) + first membership, fetched in
|
|
// parallel: the fallback query result doubles as validation when the
|
|
// preferred company happens to be the first membership, which is the
|
|
// common single-company case, so most requests pay one round trip
|
|
// instead of two sequential ones.
|
|
const [prefsRes, firstRes] = await Promise.all([
|
|
supabase
|
|
.from('user_preferences')
|
|
.select('active_company_id, locale')
|
|
.eq('user_id', userId)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(archived_at)')
|
|
.eq('user_id', userId)
|
|
.is('companies.archived_at', null)
|
|
.order('created_at', { ascending: true })
|
|
.limit(1)
|
|
.maybeSingle(),
|
|
])
|
|
|
|
const prefs = prefsRes.data
|
|
const firstCompany = firstRes.data
|
|
const locale = (prefs?.locale as string | undefined) ?? null
|
|
|
|
// A FAILED query (as opposed to one returning no rows) means the user's
|
|
// companies are unknown right now, not absent: flag it so the caller
|
|
// fails open instead of redirecting to onboarding or clearing cookies
|
|
// (issue #1053). Middleware cannot throw usefully, hence a flag.
|
|
if (prefsRes.error || firstRes.error) {
|
|
console.error(
|
|
'[middleware] company resolution query failed',
|
|
prefsRes.error ?? firstRes.error
|
|
)
|
|
return { companyId: null, locale, degraded: true }
|
|
}
|
|
|
|
if (prefs?.active_company_id) {
|
|
if (prefs.active_company_id === firstCompany?.company_id) {
|
|
return { companyId: firstCompany.company_id, locale, degraded: false }
|
|
}
|
|
|
|
const { data: membership, error: membershipError } = await supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(archived_at)')
|
|
.eq('company_id', prefs.active_company_id)
|
|
.eq('user_id', userId)
|
|
.is('companies.archived_at', null)
|
|
.maybeSingle()
|
|
|
|
// A failed validation must not silently switch the user onto their
|
|
// first membership (wrong company for consultants): degrade instead.
|
|
if (membershipError) {
|
|
console.error('[middleware] company preference validation failed', membershipError)
|
|
return { companyId: null, locale, degraded: true }
|
|
}
|
|
|
|
if (membership) return { companyId: membership.company_id, locale, degraded: false }
|
|
}
|
|
|
|
// 2. Fallback: first non-archived membership (already fetched above)
|
|
if (!firstCompany) return { companyId: null, locale, degraded: false }
|
|
|
|
// Write the fallback back to user_preferences so future RLS lookups
|
|
// see the same active company without needing this fallback scan.
|
|
// Non-fatal on failure: resolution for this request already succeeded,
|
|
// the write-back is an optimization, but log it so silent persistence
|
|
// failures (#701) are observable.
|
|
const { error: writeBackError } = await supabase
|
|
.from('user_preferences')
|
|
.upsert(
|
|
{ user_id: userId, active_company_id: firstCompany.company_id },
|
|
{ onConflict: 'user_id' }
|
|
)
|
|
|
|
if (writeBackError) {
|
|
console.error('[middleware] active company write-back failed', writeBackError)
|
|
}
|
|
|
|
return { companyId: firstCompany.company_id, locale, degraded: false }
|
|
}
|