diff --git a/DECISIONS.md b/DECISIONS.md index d4273f31..8ed0734b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1384,4 +1384,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys. [2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total. [2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none). +[2026-08-31] gnubok-home-ok cache cookie is user-scoped (userId~host) instead of cleared on sign-out: sign-out happens client-side via supabase.auth.signOut so no server surface reliably sees it, while a value bound to the session's user makes any inherited verdict miss the cache by construction. Separator ~ because it is unreserved under encodeURIComponent AND a legal raw cookie octet, so the value round-trips identically whether or not the cookie layer percent-encodes. Old host-only cookies never match and self-heal; found via the amnas account-switch repro (two logins 9 s apart shared the verdict). [2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated. diff --git a/lib/supabase/__tests__/middleware.test.ts b/lib/supabase/__tests__/middleware.test.ts index 55abea47..8a952c8d 100644 --- a/lib/supabase/__tests__/middleware.test.ts +++ b/lib/supabase/__tests__/middleware.test.ts @@ -753,7 +753,7 @@ describe('updateSession redirect destinations', () => { expect(response.status).toBe(200) expect(response.headers.get('set-cookie')).toContain( - 'gnubok-home-ok=arbore.accounted.se', + 'gnubok-home-ok=user-1~arbore.accounted.se', ) }) @@ -769,7 +769,7 @@ describe('updateSession redirect destinations', () => { expect(response.status).toBe(200) expect(locationOf(response)).toBeNull() expect(response.headers.get('set-cookie')).toContain( - 'gnubok-home-ok=app.gnubok.se', + 'gnubok-home-ok=user-1~app.gnubok.se', ) }) @@ -785,7 +785,7 @@ describe('updateSession redirect destinations', () => { expect(response.status).toBe(200) expect(response.headers.get('set-cookie')).toContain( - 'gnubok-home-ok=app.gnubok.se', + 'gnubok-home-ok=user-1~app.gnubok.se', ) }) @@ -861,7 +861,7 @@ describe('updateSession redirect destinations', () => { expect(response.status).toBe(200) expect(locationOf(response)).toBeNull() expect(response.headers.get('set-cookie')).toContain( - 'gnubok-home-ok=arbore.accounted.se', + 'gnubok-home-ok=user-1~arbore.accounted.se', ) expect(isEmailOnBrandAllowlist).toHaveBeenCalledWith( 'brand-arbore', @@ -916,17 +916,41 @@ describe('updateSession redirect destinations', () => { } }) - it('skips the check while the host-scoped OK cookie is fresh', async () => { + it('skips the check while this user\'s OK cookie for this host is fresh', async () => { state.byraMemberships = [ { teams: { kind: 'byra', brands: { domain: 'acount.accounted.se' } } }, ] const response = await runAt(ARBORE, '/', { - cookie: 'gnubok-home-ok=arbore.accounted.se', + cookie: 'gnubok-home-ok=user-1~arbore.accounted.se', }) expect(response.status).toBe(200) }) + + it('ignores an OK cookie left behind by a DIFFERENT user and still bounces', async () => { + // The amnas account-switch repro (2026-08-31): the byrå owner signs in + // on the brand host (cookie set), signs out, and a second account with + // no ties to the brand signs in within the TTL window. The inherited + // host-only verdict skipped the bounce; the user-scoped value must not. + state.hostBrand = { teamId: 'team-arbore', id: 'brand-arbore' } + + const response = await runAt(ARBORE, '/', { + cookie: 'gnubok-home-ok=user-OTHER~arbore.accounted.se', + }) + + expect(locationOf(response)).toBe('https://app.gnubok.se/') + }) + + it('ignores a stale host-only cookie from the pre-user-scoped format', async () => { + state.hostBrand = { teamId: 'team-arbore', id: 'brand-arbore' } + + const response = await runAt(ARBORE, '/', { + cookie: 'gnubok-home-ok=arbore.accounted.se', + }) + + expect(locationOf(response)).toBe('https://app.gnubok.se/') + }) }) // ── MFA semantics that must not change ──────────────────────────────── diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index a98a8e5c..fba1bc39 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -42,13 +42,28 @@ import { 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. + * 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. + * + * The value is scoped to BOTH the user and the host (`userId~host`): a + * host-only value let anyone who signed in within the TTL window inherit the + * previous user's "this is home" verdict in the same browser, skipping the + * brand-host bounce entirely (found via the amnas account-switch repro, + * 2026-08-31). A stale host-only cookie from before this change simply never + * matches, so the check re-runs and the format migrates itself. The `~` + * separator is unreserved under encodeURIComponent AND a legal raw cookie + * octet, so the value round-trips byte-identically whether or not the cookie + * layer percent-encodes. */ const HOME_DOMAIN_OK_COOKIE = 'gnubok-home-ok' const HOME_DOMAIN_OK_MAX_AGE = 15 * 60 +/** The `userId~host` value a home-ok cookie must carry to skip the check. */ +function homeDomainOkValue(userId: string, host: string): string { + return `${userId}~${host}` +} + /** * 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 @@ -368,7 +383,7 @@ async function updateSessionInner( if (homeOutcome.cacheOk) { supabaseResponse.cookies.set( HOME_DOMAIN_OK_COOKIE, - normalizeHost(request.nextUrl.hostname), + homeDomainOkValue(user.id, normalizeHost(request.nextUrl.hostname)), { path: '/', httpOnly: true, @@ -767,9 +782,10 @@ function isAffinityExemptHost(host: string): boolean { * 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. + * `cacheOk` marks a positive "this is home" verdict, cached by the caller in + * a cookie scoped to this user and host. 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, @@ -780,7 +796,12 @@ async function resolveHomeDomainOutcome( 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 + // The cached verdict must belong to THIS user: a host-only match let a + // second account signed in within the TTL window ride the first account's + // verdict and skip the brand-host bounce. + if (request.cookies.get(HOME_DOMAIN_OK_COOKIE)?.value === homeDomainOkValue(userId, host)) { + return stay + } // Rule 1: the user's own byrå brand domains (RLS: members read their brand). const { data: byraRows, error: byraError } = await supabase