diff --git a/DECISIONS.md b/DECISIONS.md index f2a5798e..8fcecf95 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1373,6 +1373,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/lib/supabase/__tests__/middleware.test.ts b/lib/supabase/__tests__/middleware.test.ts index ae4c1c56..55abea47 100644 --- a/lib/supabase/__tests__/middleware.test.ts +++ b/lib/supabase/__tests__/middleware.test.ts @@ -10,7 +10,11 @@ import { NextRequest } from 'next/server' */ const state = vi.hoisted(() => ({ - user: null as null | { id: string; app_metadata?: Record }, + user: null as null | { + id: string + email?: string + app_metadata?: Record + }, 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>, 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()), 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()), + 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' }] diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index 5583f5d7..a98a8e5c 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -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, 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.