fix(auth): exempt allowlisted emails from home-domain brand-host bounce (#2077)

A signup-allowlisted partner owner (brand_signup_allowlist) could sign up
on an invite-only brand domain but was then exiled to the canonical domain
by the middleware home-domain affinity rule before any team membership
existed: the layout-level exemption in resolveBrandDomainBounce was
unreachable because the middleware redirect runs first. Rule 2 now checks
the allowlist (fail-closed) and lets those sessions stay, caching the
verdict like other home verdicts. Found via the Ziffr owner lockout.


Claude-Session: https://claude.ai/code/session_011eQDGWLppyy6BZ6Rx6shjd

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-31 13:44:31 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1a3686dd45
commit 024420d655
3 changed files with 79 additions and 4 deletions
+1
View File
@@ -1373,6 +1373,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-30] book_skattekonto_row(s) tier 'medium' + scope 'transactions:write': rule-driven booking with no caller-supplied lines mirrors book_mileage_period (not create_voucher's 'high'); scope follows reconcile_residual (books an outside row). Commit service gates on SKATTEVERKET_ENABLED for HTTP-dispatcher parity, recoverable so the op stays pending.
[2026-08-30] Reminder text overrides (company_settings.reminder_text_overrides, level_1..3 x subject/body): the defaults are expressed as placeholder patterns (REMINDER_EMAIL_DEFAULT_TEXTS) and BOTH the stock mail and overrides render through the same substitution pipeline (applyPlaceholders + escape per output variant), so the settings-UI prefill is byte-for-byte the mail that goes out and cannot drift; this differs from the invoice_email_texts precedent, whose hand-written pattern forms can drift from the coded defaults. The level-3 default body is now an explicit inkassovarning (8 days, fordran till inkasso, costs per lag (1981:739)) but the level TITLE stays 'Slutlig paminnelse': the title is reused as the level name in settings labels and subject prefix, and renaming it everywhere is wording churn beyond the ask. An overridden subject owns the whole line (no automatic ' (inkl. drojsmalsranta)' suffix; {belopp} already includes surcharges), the stock subject keeps the suffix byte-identically. No pg test for the migration: a declarative CHECK (jsonb_typeof object) identical in shape to invoice_email_texts (20260703091000), which also shipped without one. The v1 REST/MCP update_company_settings surface was NOT extended: it is a curated field set with staged operations and its own placeholder refinement, a separate parity slice. typecheck/antipattern baselines deliberately not ratcheted in this diff: both one-count drops predate the branch (main drift), gates only fail on increase.
[2026-08-30] PR #2021 round 2 (#546): the relayed Peppol buyer restriction now says the customer's org number must not be a personnummer (prepareParty('buyer') in lib/invoices/peppol-bis-billing.ts refuses it with BUYER_PARTICIPANT_IDENTIFIER_UNSUPPORTED, so an enskild firma CUSTOMER is refused, not only an enskild firma sender), Step 4 of the invoicing-rules workflow points at the Peppol section so a top-down reader never reaches the external-provider fallback first, the mark-sent recovery is scoped to the still-draft invoice in every text (INVOICE_MARK_SENT_REPAIR_REQUIRED leaves the invoice sent with the verifikat posted and a second mark-sent returns 409; the reviewer's proposed repair tool gnubok_link_invoice_to_voucher is the PAYMENT link and requires status sent/overdue/partially_paid, so no tool is named and the repair is left to support), and the verifikat parenthetical says "under faktureringsmetoden" (kontantmetod and defer_invoice_booking companies get none at issue). The guard test now also pins the two v1 route descriptions by reading the route source (apiskill:check only detects generated-vs-source drift, not a truth regression). The atom bump was seeded as a THIRD append-only migration (20260830101500, atom v9) rather than consolidating to one: the Supabase preview branch for the PR (xxnqggttsefleehmarjo) has applied both 20260829000100 and 20260829010000 per its schema_migrations, so deleting either would leave a remote with versions absent from the repo, the orphan class the migration rule forbids; all three seeds are idempotent upserts with the version guard, so prod applying them in sequence ends at v9. The generator's max-plus-one name (20260829010001) was renamed to 20260830101500 for the same reason as round 1 (newer than every file on origin/main and every sibling worktree; skills:check hashes content, the pg replay test globs the seed).
[2026-08-31] Home-domain affinity keeps allowlist as signup gate only: middleware exempts allowlisted emails from the brand-host bounce instead of auto-joining them to the partner team; membership stays an explicit ops step (authorization != signup permission).
[2026-08-30] Non-invoice amount fallback uses a new prominentAmounts extraction field at reduced match weight (0.3 vs 0.4), not a relaxed totals.total: "total = what the buyer pays" keeps invoice/receipt booking paths unregressed, and one-of-several printed figures agreeing is weaker evidence than a total agreeing. Candidate floor (0.6) deliberately unchanged, so a dateless bankintyg still only surfaces via the manual picker.
[2026-08-30] Skeptic pass on PR #2048 replaced the fallback's reduced amount WEIGHT (0.3) with a flat confidence FACTOR (x0.85): normalization made the reduced weight both let date+amount-only fallbacks reach 1.0 and score a DISAGREEING fallback above a disagreeing total (0.67 vs 0.60). Fallback candidates additionally require the document date within DATE_TOLERANCE_DAYS on the agent surface, and the match reason names the matched figure + document label since total_amount stays null.
[2026-08-30] Pre-migration inbox marker cutoff derived at query time from max posted source_type='import' entry_date (excl. series M) instead of a stored sie_imports coverage column: no migration/backfill needed and undo/replace self-corrects; series M excluded because the importer's omforingsverifikation is dated at fiscal year end.
+59 -3
View File
@@ -10,7 +10,11 @@ import { NextRequest } from 'next/server'
*/
const state = vi.hoisted(() => ({
user: null as null | { id: string; app_metadata?: Record<string, unknown> },
user: null as null | {
id: string
email?: string
app_metadata?: Record<string, unknown>
},
sessionId: 'session-1' as string | null,
authError: null as unknown,
aal: null as null | { currentLevel: string; nextLevel: string },
@@ -40,7 +44,9 @@ const state = vi.hoisted(() => ({
clientMemberships: [] as Array<Record<string, unknown>>,
clientMembershipsError: null as unknown,
// What the mocked resolveBrandByHost returns for the request host.
hostBrand: null as null | { teamId: string },
hostBrand: null as null | { teamId: string; id?: string },
// What the mocked isEmailOnBrandAllowlist returns (Rule 2 exemption).
allowlisted: false,
signOut: vi.fn(async () => ({ error: null })),
// Row returned for user_preferences reads (the auto_logout mint lookup).
userPreferences: null as null | { auto_logout: boolean },
@@ -119,10 +125,17 @@ vi.mock('@/lib/logger', () => {
vi.mock('@/lib/branding/resolve', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/branding/resolve')>()),
resolveBrandByHost: vi.fn(async () =>
state.hostBrand ? { teamId: state.hostBrand.teamId } : null,
state.hostBrand
? { id: state.hostBrand.id ?? 'brand-host', teamId: state.hostBrand.teamId }
: null,
),
}))
vi.mock('@/lib/auth/brand-signup-gate', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/auth/brand-signup-gate')>()),
isEmailOnBrandAllowlist: vi.fn(async () => state.allowlisted),
}))
import { updateSession } from '../middleware'
import {
createSessionTimeoutState,
@@ -173,6 +186,7 @@ describe('updateSession redirect destinations', () => {
state.clientMemberships = []
state.clientMembershipsError = null
state.hostBrand = null
state.allowlisted = false
state.userPreferences = null
state.userPreferencesError = null
delete process.env.NEXT_PUBLIC_REQUIRE_MFA
@@ -834,6 +848,48 @@ describe('updateSession redirect destinations', () => {
expect(locationOf(response)).toBe('https://app.gnubok.se/')
})
it('keeps an allowlisted email on the brand domain before any membership exists', async () => {
// The partner owner between allowlisted signup and team provisioning:
// no team_members row, no company, only a brand_signup_allowlist entry.
const { isEmailOnBrandAllowlist } = await import('@/lib/auth/brand-signup-gate')
state.user = { ...SIGNED_IN, email: 'owner@partner.example' }
state.hostBrand = { teamId: 'team-arbore', id: 'brand-arbore' }
state.allowlisted = true
const response = await runAt(ARBORE, '/')
expect(response.status).toBe(200)
expect(locationOf(response)).toBeNull()
expect(response.headers.get('set-cookie')).toContain(
'gnubok-home-ok=arbore.accounted.se',
)
expect(isEmailOnBrandAllowlist).toHaveBeenCalledWith(
'brand-arbore',
'owner@partner.example',
)
})
it('still redirects a non-allowlisted email off the brand domain', async () => {
state.user = { ...SIGNED_IN, email: 'stranger@example.com' }
state.hostBrand = { teamId: 'team-arbore', id: 'brand-arbore' }
state.allowlisted = false
const response = await runAt(ARBORE, '/')
expect(locationOf(response)).toBe('https://app.gnubok.se/')
})
it('skips the allowlist lookup for a user without an email', async () => {
const { isEmailOnBrandAllowlist } = await import('@/lib/auth/brand-signup-gate')
state.hostBrand = { teamId: 'team-arbore' }
state.allowlisted = true
const response = await runAt(ARBORE, '/')
expect(locationOf(response)).toBe('https://app.gnubok.se/')
expect(isEmailOnBrandAllowlist).not.toHaveBeenCalled()
})
it('keeps a byrå client user on the byrå domain their company lives under', async () => {
state.hostBrand = { teamId: 'team-arbore' }
state.clientMemberships = [{ company_id: 'company-1' }]
+19 -1
View File
@@ -14,6 +14,7 @@ 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 { isEmailOnBrandAllowlist } from '@/lib/auth/brand-signup-gate'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { normalizeHost, resolveBrandByHost } from '@/lib/branding/resolve'
import {
@@ -355,7 +356,12 @@ async function updateSessionInner(
// 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)
const homeOutcome = await resolveHomeDomainOutcome(
supabase,
user.id,
user.email ?? null,
request,
)
if (homeOutcome.redirectTo) {
return NextResponse.redirect(homeOutcome.redirectTo)
}
@@ -768,6 +774,7 @@ function isAffinityExemptHost(host: string): boolean {
async function resolveHomeDomainOutcome(
supabase: ReturnType<typeof createServerClient>,
userId: string,
userEmail: string | null,
request: NextRequest,
): Promise<{ redirectTo: URL | null; cacheOk: boolean }> {
const stay = { redirectTo: null, cacheOk: false }
@@ -857,6 +864,17 @@ async function resolveHomeDomainOutcome(
}
if ((clientRows ?? []).length > 0) return { redirectTo: null, cacheOk: true }
// A signup-allowlisted email stays on the brand host even before any
// membership exists: the partner owner between allowlisted signup and team
// provisioning. Without this the layout-level exemption in
// resolveBrandDomainBounce (lib/auth/brand-signup-gate.ts) is unreachable,
// because this redirect runs first. Fail-closed lookup: an allowlist query
// error reads as "not allowlisted" and falls through to the platform
// redirect, which is the pre-existing behavior.
if (userEmail && (await isEmailOnBrandAllowlist(hostBrand.id, userEmail))) {
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.