* feat(skatteverket): repair notification recipients + make the agent the SKV notification surface The company_members -> profiles!inner(email) PostgREST embed has no FK to traverse (company_members.user_id references auth.users), so it 400'd and silently killed all four notification emails since they shipped. Recipient lookup is now a shared two-step helper (lib/notifications/member-email): kvittens confirmations, skattekonto drift alerts (tax-contact routing preserved via the plural variant) and backup alerts deliver again. The connection-expired email is deleted instead of fixed: with SKV's 65-minute personal sessions it was one mail per connect (see DECISIONS.md); the event and needs_reconsent flagging stay. For MCP-first users the agent is the notification surface, so: - SKATTEVERKET_NOT_CONNECTED copy is now agent-directive: session expiry is normal (~1h by SKV design), only a person can reconnect with BankID, do not retry until they confirm. Inline strings (declaration-status, read routes, v1 pitfalls, accounted-api skill) aligned. - gnubok_get_agent_briefing gains an optional skatteverket_connection block (status/source/connected_at + directive message on needs_reconsent), emitted only when a connection or verified system grant exists, so agents warn the user at session start instead of failing mid-task. Payload bench ceiling bumped 59.95K -> 60.15K for the outputSchema contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): drift email resolves recipients via service client; review fixes The skeptic pass refuted the drift-email repair: skattekonto.drift_detected is emitted only by the nightly cron, and the extension registry builds each event handler a fresh ctx from the anonymous cookie client (or none at all on cookieless requests), so RLS returned zero company_members rows and the two-step lookup still resolved no recipient. The handler now builds its own service-role client, the same documented pattern as the retired connection-expired handler; drift tests exercise the handler without ctx, matching the cron reality. CodeRabbit findings: resolveMemberEmails pages both queries through fetchAllRows with stable ordering (PostgREST caps unpaged reads at 1000 rows); the v1 vat-declarations pitfall and regenerated accounted-api docs now name both auth paths (member BankID connection or verified ombud grant); the briefing's system-before-user priority carries a cross-reference to resolveReadAuth explaining why it is not reused. member-email.ts JSDoc states the service-role-client requirement (profiles RLS is own-row-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
169 lines
6.5 KiB
TypeScript
169 lines
6.5 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { getSkatteverketEnvironment, type SkvAuth } from './api-client'
|
|
import { getSystemAuthMode, isSystemAuthConfigured } from './system-auth/config'
|
|
import {
|
|
getConnection,
|
|
type SkvBehorighet,
|
|
type SkvEnvironment,
|
|
} from './connection-store'
|
|
|
|
const log = createLogger('skatteverket-resolve-auth')
|
|
|
|
/**
|
|
* Auth resolution for READ paths (skattekonto sync, kvittens polling, moms
|
|
* inlamnat/beslutat checks, the skattekonto page's connected check).
|
|
*
|
|
* Preference order:
|
|
* 1. System credentials, when SKATTEVERKET_SYSTEM_AUTH_MODE=on, the system
|
|
* flow is configured, and the company's connection row shows the
|
|
* required behorighet as granted for the current environment.
|
|
* 2. A user token connected to the company. Token rows are per
|
|
* (user_id, company_id), so a company can hold one row per member who
|
|
* ran the BankID consent. The caller's own row is preferred when it
|
|
* exists; otherwise the most recently issued active row for the company
|
|
* is used. That is what lets member B read data member A connected
|
|
* (issue #1673): the fetched skattekonto/declaration data belongs to
|
|
* the company, not to the person who happened to press "Anslut".
|
|
*
|
|
* Who may read: any member of the company. Membership is enforced upstream
|
|
* (the extension dispatcher resolves ctx.companyId from the caller's own
|
|
* memberships, and the token table's SELECT policy is company-scoped), so no
|
|
* extra role check is added here. Reads never move the row: refresh writes
|
|
* go back to the token owner's row (auth.userId is the owner, not the
|
|
* caller).
|
|
*
|
|
* Write paths (moms utkast/las, AGI submit/spara/granskningsunderlag) stay
|
|
* hard-wired to user mode with the CALLER's own token: the personal flow
|
|
* needs no ombud grant and the BankID signing step is personal by nature.
|
|
* Connect/disconnect likewise only touch the caller's own row.
|
|
*
|
|
* Retiring user-token reads later (the full ombud switch) is a policy change
|
|
* inside this function only.
|
|
*/
|
|
|
|
export function currentSkvEnvironment(): SkvEnvironment {
|
|
return getSkatteverketEnvironment() === 'prod' ? 'production' : 'test'
|
|
}
|
|
|
|
export type ResolvedReadAuth =
|
|
| {
|
|
ok: true
|
|
auth: SkvAuth
|
|
source: 'system' | 'user'
|
|
/** The token-owning user (notification recipient); null in pure system mode. */
|
|
tokenUserId: string | null
|
|
}
|
|
| { ok: false; reason: 'no_token' | 'needs_reconsent' }
|
|
|
|
/** True when the company's connection row has the behorighet granted. */
|
|
export async function hasVerifiedGrant(
|
|
companyId: string,
|
|
behorighet: SkvBehorighet
|
|
): Promise<boolean> {
|
|
const connection = await getConnection(companyId, currentSkvEnvironment())
|
|
if (!connection) return false
|
|
const grant =
|
|
behorighet === 'lasombud' ? connection.lasombud_status : connection.moms_ombud_status
|
|
return grant === 'granted' && ['verified', 'partial'].includes(connection.status)
|
|
}
|
|
|
|
export async function resolveReadAuth(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
opts: { requires: SkvBehorighet; userId?: string }
|
|
): Promise<ResolvedReadAuth> {
|
|
if (getSystemAuthMode() === 'on' && isSystemAuthConfigured()) {
|
|
if (await hasVerifiedGrant(companyId, opts.requires)) {
|
|
return {
|
|
ok: true,
|
|
auth: { mode: 'system' },
|
|
source: 'system',
|
|
tokenUserId:
|
|
opts.userId ?? (await findCompanyTokenUser(supabase, companyId))?.userId ?? null,
|
|
}
|
|
}
|
|
}
|
|
|
|
// The caller's own token wins when it exists; any other member's active
|
|
// token serves otherwise. Passing userId no longer short-circuits to "the
|
|
// caller's row or nothing": a member who never connected used to resolve
|
|
// to their own missing row and see NOT_CONNECTED for a company that is
|
|
// connected (#1673).
|
|
const token = await findCompanyTokenUser(supabase, companyId, { preferUserId: opts.userId })
|
|
if (!token) return { ok: false, reason: 'no_token' }
|
|
if (token.needsReconsent) return { ok: false, reason: 'needs_reconsent' }
|
|
|
|
return {
|
|
ok: true,
|
|
auth: { mode: 'user', supabase, userId: token.userId, companyId },
|
|
source: 'user',
|
|
tokenUserId: token.userId,
|
|
}
|
|
}
|
|
|
|
export interface CompanyTokenUser {
|
|
userId: string
|
|
needsReconsent: boolean
|
|
/**
|
|
* When the serving row was issued (last consent or refresh; storeTokens is
|
|
* DELETE + INSERT). Personal SKV sessions live ~65 minutes from this time,
|
|
* so consumers (the agent briefing) can reason about likely expiry.
|
|
*/
|
|
createdAt: string | null
|
|
}
|
|
|
|
/**
|
|
* Pick the token row that should serve a read for this company.
|
|
*
|
|
* Deterministic order:
|
|
* 1. active rows before needs_reconsent rows (a dead row must not shadow a
|
|
* live one connected by another member)
|
|
* 2. within the same status, the preferred user's own row first
|
|
* 3. then the most recently issued row (storeTokens is DELETE + INSERT, so
|
|
* created_at is the last consent or refresh)
|
|
*
|
|
* Reads all of the company's rows (one per connected member; a handful at
|
|
* most) instead of `.maybeSingle()`, which errors on the second row and used
|
|
* to turn "two members connected" into "nobody connected" for everyone.
|
|
*
|
|
* Returns null when the company has no token row at all. The token table's
|
|
* SELECT policy is company-scoped, so a user-session client only ever sees
|
|
* rows for companies the caller belongs to.
|
|
*/
|
|
export async function findCompanyTokenUser(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
opts: { preferUserId?: string } = {}
|
|
): Promise<CompanyTokenUser | null> {
|
|
const { data, error } = await supabase
|
|
.from('skatteverket_tokens')
|
|
.select('user_id, status, created_at')
|
|
.eq('company_id', companyId)
|
|
.order('created_at', { ascending: false })
|
|
|
|
if (error) {
|
|
log.warn('failed to look up company token rows', { companyId, error: error.message })
|
|
return null
|
|
}
|
|
|
|
const rows = (
|
|
(data ?? []) as Array<{ user_id: string | null; status: string | null; created_at: string | null }>
|
|
).filter(
|
|
(row): row is { user_id: string; status: string | null; created_at: string | null } =>
|
|
typeof row.user_id === 'string'
|
|
)
|
|
if (rows.length === 0) return null
|
|
|
|
const active = rows.filter(row => row.status !== 'needs_reconsent')
|
|
const pool = active.length > 0 ? active : rows
|
|
const own = opts.preferUserId ? pool.find(row => row.user_id === opts.preferUserId) : undefined
|
|
const pick = own ?? pool[0]
|
|
|
|
return {
|
|
userId: pick.user_id,
|
|
needsReconsent: pick.status === 'needs_reconsent',
|
|
createdAt: pick.created_at ?? null,
|
|
}
|
|
}
|