diff --git a/DECISIONS.md b/DECISIONS.md index 0b7263c4..e1cbb6e6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1194,6 +1194,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva"). [2026-08-25] /reports/bank-reconciliation retired behind a redirect to /reconciliation instead of kept as a "power" page: everything it did (matcher, manual N:1 matching, residual booking, IB tag, move-to-account) lives on the account-keyed page, and two reconciliation surfaces meant two truths. The catalog slug stays so old links, the report library and ?autorun=1 deep links keep working. [2026-08-25] reconciliation_residual staged op tiered 'medium', not create_voucher's 'high': it books one typed verifikat (6570/8410/8310/3740 vs bank) bounded by RESIDUAL_MAX_AMOUNT and is undone by storno + unmatch, i.e. the same blast radius as categorize_transaction. Scope is transactions:write (same as the v1 route) because it writes the ledger. +[2026-08-25] gnubok_update_customer keeps catalogVisibility 'search' even though #1876 suggested reconsidering it: the tools/list payload guard sits at the 59.95K ceiling with zero headroom, and listing the tool would inline its full inputSchema + STAGED_OPERATION_SCHEMA + _meta. The tool stays reachable via gnubok_search_tools; revisit only together with a payload-budget pass. personal_number on update reuses create's staging crypto (personal_number_encrypted in params, masked-only preview) and mirrors the REST PATCH semantics: masked echo = unchanged, null = clear; idempotency hashing switches to the masked preview only for personnummer-bearing updates so every other update keeps its previous hash identity. [2026-08-25] Rot/rut candidate list (#1884) treats a partially_paid invoice whose customer share is settled as claimable, instead of only surfacing it as blocked: the share outstanding is DERIVED as total - paid_amount - deduction_total (the buildInvoiceWriteData / migration 20260817191708 formula) rather than read off remaining_amount, because payment-sync's storno path recomputes remaining_amount without subtracting the deduction (skeptic-proven divergence), so the stored column is not a deterministic signal while the three header fields are maintained by every settlement path. Current settlement code flips such invoices to paid at exactly derived-share 0, and a legacy row stuck at partially_paid has NO user repair path (a 0-kr payment is rejected as overpayment), so blocked-with-reason would explain the dead end without opening it. The gate lives in evaluateInvoiceForFile so the list and file generation can never disagree. Decided (paid/partially_paid) begaran items are omitted from BOTH lists before any other classification, wrong-type included (skeptic-caught ordering hole): finished business, visible in the request history, and surfacing them would flood the list forever. DEDUCTION_TOTAL_MISSING (lines claim a deduction the header never recorded) blocks file generation too, not just the list: the 1513 receivable was never booked, so requesting the line amounts would claim money the ledger does not carry. [2026-08-25] Notification recipient lookup is a two-step query (lib/notifications/member-email), not the company_members -> profiles!inner(email) embed and not a new FK: company_members.user_id references auth.users, so PostgREST has no relationship to traverse and the embed 400'd, silently killing all four notification emails (kvittens, drift, backup, connection-expired) since they shipped. Adding an FK to profiles would be a migration on a core tenancy table for zero functional gain. Second lesson recorded: the drift path DID log the failure and nobody read it, so the guard is post-deploy delivery verification, not more logging. [2026-08-25] The skattekonto connection-expired email is deleted, not fixed: SKV's per-flow refresh tokens live 65 minutes, so per-consent-episode dedup means one "your connection expired" mail per connect, arriving an hour after every successful BankID login; that trains users to ignore mail. Rejected alternative (kept for revisit): fix + throttle to one mail per user per 7 days, fired only when a scheduled sync actually failed. Residual accepted knowingly: web-only users now have NO proactive channel for a dead SKV connection (banner needs a visit, briefing needs an agent, drift email needs a working sync); the agent briefing's skatteverket_connection block and the rewritten SKATTEVERKET_NOT_CONNECTED copy are the compensating surfaces. The skattekonto.connection.expired event and needs_reconsent flagging stay. diff --git a/extensions/general/mcp-server/__tests__/update-customer.test.ts b/extensions/general/mcp-server/__tests__/update-customer.test.ts index 1fb06c09..39d9197e 100644 --- a/extensions/general/mcp-server/__tests__/update-customer.test.ts +++ b/extensions/general/mcp-server/__tests__/update-customer.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createQueuedMockSupabase } from '@/tests/helpers' import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers' +import { hashRequest } from '@/lib/api/idempotency' +import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer' import { tools } from '../server' const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111' @@ -41,9 +43,9 @@ describe('gnubok_update_customer: registration', () => { expect(OPERATION_RISK_TIERS.update_customer).toBe('low') }) - it('does not expose personal_number as an input', () => { - const properties = tool().inputSchema.properties as Record - expect(properties).not.toHaveProperty('personal_number') + it('exposes personal_number in the strict input schema, nullable for clearing', () => { + const properties = tool().inputSchema.properties as Record> + expect(properties.personal_number).toMatchObject({ type: ['string', 'null'] }) }) it('keeps the wide write schema discoverable through tool search', async () => { @@ -166,3 +168,253 @@ describe('gnubok_update_customer: validation and staging', () => { expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations') }) }) + +// ── personal_number (#1876) ─────────────────────────────────────────── +// +// Synthetic personnummer, never a real one. REST PATCH semantics +// (app/api/customers/[id]/route.ts): plaintext sets (encrypted at staging), +// masked echo means unchanged, explicit null clears. +const PERSONAL_NUMBER = '19900101-1234' +const MASKED = '********-1234' +const CIPHERTEXT_SHAPE = /^[0-9a-f]{76,255}$/ + +type StagedInsert = { + params: { changes: Record } + preview_data: { + current: Record + changes: Record + proposed: Record + } +} + +describe('gnubok_update_customer: personal_number', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('stages the personnummer encrypted and previews it masked, never in plaintext', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: currentCustomer({ customer_type: 'individual', org_number: null }) }) + enqueue({ data: { id: 'op-pn-1' } }) + + const result = (await tool().execute( + { customer_id: CUSTOMER_ID, personal_number: PERSONAL_NUMBER }, + 'company-1', + 'user-1', + supabase as never, + )) as { staged: boolean; preview: StagedInsert['preview_data'] } + + expect(result.staged).toBe(true) + expect(result.preview.changes.personal_number_masked).toBe(MASKED) + expect(result.preview.proposed.personal_number_masked).toBe(MASKED) + expect(result.preview.changes).not.toHaveProperty('personal_number') + expect(result.preview.changes).not.toHaveProperty('personal_number_encrypted') + expect(JSON.stringify(result)).not.toContain(PERSONAL_NUMBER) + + const inserted = findCall('pending_operations', 'insert')?.[0] as StagedInsert + expect(inserted.params.changes.personal_number_encrypted).toMatch(CIPHERTEXT_SHAPE) + expect(decryptPersonnummer(inserted.params.changes.personal_number_encrypted as string)).toBe(PERSONAL_NUMBER) + expect(inserted.params.changes).not.toHaveProperty('personal_number') + expect(inserted.preview_data.changes.personal_number_masked).toBe(MASKED) + expect(inserted.preview_data.changes).not.toHaveProperty('personal_number_encrypted') + // Nothing persisted carries the plaintext: not params, not preview, not title. + expect(JSON.stringify(inserted)).not.toContain(PERSONAL_NUMBER) + }) + + it.each([MASKED, '********-????'])( + 'treats the masked echo %s as "leave unchanged" and stages no personnummer change', + async (maskedForm) => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: currentCustomer({ customer_type: 'individual', org_number: null }) }) + enqueue({ data: { id: 'op-pn-2' } }) + + const result = (await tool().execute( + { customer_id: CUSTOMER_ID, personal_number: maskedForm, city: 'New City' }, + 'company-1', + 'user-1', + supabase as never, + )) as { staged: boolean; preview: StagedInsert['preview_data'] } + + expect(result.staged).toBe(true) + expect(result.preview.changes).toEqual({ city: 'New City' }) + + const inserted = findCall('pending_operations', 'insert')?.[0] as StagedInsert + expect(inserted.params.changes).toEqual({ city: 'New City' }) + }, + ) + + it('rejects an update whose only field is the masked echo (a no-op)', async () => { + const { supabase } = createQueuedMockSupabase() + + await expect( + tool().execute( + { customer_id: CUSTOMER_ID, personal_number: MASKED }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/at least one/i) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('stages an explicit null as a clear', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: currentCustomer({ customer_type: 'individual', org_number: null }) }) + enqueue({ data: { id: 'op-pn-3' } }) + + const result = (await tool().execute( + { customer_id: CUSTOMER_ID, personal_number: null }, + 'company-1', + 'user-1', + supabase as never, + )) as { staged: boolean; preview: StagedInsert['preview_data'] } + + expect(result.staged).toBe(true) + expect(result.preview.changes.personal_number_masked).toBeNull() + expect(result.preview.proposed.personal_number_masked).toBeNull() + + const inserted = findCall('pending_operations', 'insert')?.[0] as StagedInsert + expect(inserted.params.changes.personal_number_encrypted).toBeNull() + }) + + it('shows the stored personnummer masked in the current preview, never the ciphertext', async () => { + const stored = encryptPersonnummer(PERSONAL_NUMBER) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: currentCustomer({ customer_type: 'individual', org_number: null, personal_number: stored }), + }) + enqueue({ data: { id: 'op-pn-4' } }) + + const result = (await tool().execute( + { customer_id: CUSTOMER_ID, city: 'New City' }, + 'company-1', + 'user-1', + supabase as never, + )) as { staged: boolean; preview: StagedInsert['preview_data'] } + + expect(result.preview.current.personal_number_masked).toBe(MASKED) + expect(result.preview.proposed.personal_number_masked).toBe(MASKED) + const serialized = JSON.stringify(result) + expect(serialized).not.toContain(PERSONAL_NUMBER) + expect(serialized).not.toContain(stored) + }) + + it('refuses personal_number on a business customer', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: currentCustomer() }) // swedish_business + + await expect( + tool().execute( + { customer_id: CUSTOMER_ID, personal_number: PERSONAL_NUMBER }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/individual/) + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('refuses personal_number when the same update turns the customer into a business', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: currentCustomer({ customer_type: 'individual', org_number: null }) }) + + await expect( + tool().execute( + { + customer_id: CUSTOMER_ID, + customer_type: 'swedish_business', + personal_number: PERSONAL_NUMBER, + }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/individual/) + }) + + it('rejects a malformed personal_number before querying the database', async () => { + const { supabase } = createQueuedMockSupabase() + + await expect( + tool().execute( + { customer_id: CUSTOMER_ID, personal_number: 'not-a-personnummer' }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/personnummer/) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('dry_run returns the masked preview without staging', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: currentCustomer({ customer_type: 'individual', org_number: null }) }) + + const result = (await tool().execute( + { customer_id: CUSTOMER_ID, personal_number: PERSONAL_NUMBER, dry_run: true }, + 'company-1', + 'user-1', + supabase as never, + )) as { staged: boolean; dry_run?: boolean; preview: StagedInsert['preview_data'] } + + expect(result.staged).toBe(false) + expect(result.dry_run).toBe(true) + expect(result.preview.changes.personal_number_masked).toBe(MASKED) + expect(JSON.stringify(result)).not.toContain(PERSONAL_NUMBER) + expect(findCall('pending_operations', 'insert')).toBeUndefined() + }) + + it('replays an identical retry under the same idempotency_key despite the random-IV ciphertext', async () => { + const args = { + customer_id: CUSTOMER_ID, + personal_number: PERSONAL_NUMBER, + idempotency_key: '4d9e8b1a-2c3f-4a5b-8c7d-0e1f2a3b4c5d', + } + + // First call: miss, stage, store. + const first = createQueuedMockSupabase() + first.enqueue({ data: currentCustomer({ customer_type: 'individual', org_number: null }) }) + first.enqueue({ data: null }) // idempotency_keys lookup: miss + first.enqueue({ data: { id: 'op-pn-idem' } }) // pending_operations insert + first.enqueue({ data: null }) // idempotency_keys store + const firstResult = (await tool().execute(args, 'company-1', 'user-1', first.supabase as never)) as { + operation_id?: string + preview: StagedInsert['preview_data'] + } + expect(firstResult.operation_id).toBe('op-pn-idem') + const stored = first.findCall('idempotency_keys', 'insert')?.[0] as { + request_hash: string + response_body: Record + } + // The hash is over the masked changes, which are stable across calls; + // hashing params (random-IV ciphertext) would make every retry look like + // a different payload and fail with IDEMPOTENCY_KEY_REUSE. + expect(stored.request_hash).toBe( + hashRequest({ + operationType: 'update_customer', + params: { customer_id: CUSTOMER_ID, changes: firstResult.preview.changes }, + companyId: 'company-1', + }), + ) + expect(JSON.stringify(stored)).not.toContain(PERSONAL_NUMBER) + + // Second call: hit with the stored hash; nothing new is staged. + const second = createQueuedMockSupabase() + second.enqueue({ data: currentCustomer({ customer_type: 'individual', org_number: null }) }) + second.enqueue({ + data: { + request_hash: stored.request_hash, + response_status: 'success', + response_body: stored.response_body, + expires_at: '2999-01-01T00:00:00Z', + }, + }) + const replay = (await tool().execute(args, 'company-1', 'user-1', second.supabase as never)) as { + idempotency_replay?: boolean + operation_id?: string + } + expect(replay.idempotency_replay).toBe(true) + expect(replay.operation_id).toBe('op-pn-idem') + expect(second.findCall('pending_operations', 'insert')).toBeUndefined() + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index b7961b32..68bc6462 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -95,6 +95,7 @@ import { } from '@/lib/customers/personal-number-shape' import { PERSONAL_NUMBER_PLAINTEXT_RE, + isMaskedPersonalNumber, maskCustomerPersonalNumber, } from '@/lib/customers/mask-personal-number' import { @@ -5321,6 +5322,10 @@ export const tools: McpTool[] = [ city: { type: 'string' }, country: { type: 'string' }, org_number: { type: 'string' }, + personal_number: { + type: ['string', 'null'], + description: 'Personnummer, individual customers only. Encrypted at staging, masked on read. Null clears; a masked value (********-1234) means unchanged.', + }, vat_number: { type: 'string', description: 'EU VAT numbers are revalidated with VIES when the update is approved.' }, language: { type: 'string', enum: ['sv', 'en'] }, default_payment_terms: { type: 'integer', minimum: 1 }, @@ -5359,6 +5364,35 @@ export const tools: McpTool[] = [ if (args[key] !== undefined) changes[key] = args[key] } + // personal_number never enters `changes` in plaintext: staging-pii-guard + // forbids that key in staged payloads. REST PATCH semantics + // (app/api/customers/[id]/route.ts): a masked echo of a read + // ('********-1234' or '********-????') carries no new value and is + // dropped; explicit null clears; a plaintext personnummer is validated + // here and staged as AES-256-GCM ciphertext, so the approval preview + // only ever sees the masked form. + const personalNumberArg = args.personal_number + let personalNumber: string | null = null + if (personalNumberArg !== undefined) { + if (personalNumberArg !== null && typeof personalNumberArg !== 'string') { + throw new Error('personal_number must be a string or null.') + } + const trimmed = personalNumberArg === null ? null : personalNumberArg.trim() + if (trimmed === null) { + changes.personal_number_encrypted = null + } else if (isMaskedPersonalNumber(trimmed)) { + // Echo of a read: leave the stored personnummer alone. + } else if (!PERSONAL_NUMBER_PLAINTEXT_RE.test(trimmed)) { + throw new Error( + 'personal_number must be a Swedish personnummer: YYYYMMDD-XXXX, YYMMDD-XXXX or the digits alone. ' + + 'Null clears the stored value; a masked value leaves it unchanged.', + ) + } else { + personalNumber = trimmed + changes.personal_number_encrypted = encryptCustomerPersonalNumber(trimmed) + } + } + const parsed = UpdateCustomerParamsSchema.safeParse({ customer_id: args.customer_id, changes, @@ -5370,7 +5404,7 @@ export const tools: McpTool[] = [ const { data: current, error } = await supabase .from('customers') - .select('id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, language, default_payment_terms, notes') + .select('id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, language, default_payment_terms, notes, personal_number') .eq('id', parsed.data.customer_id) .eq('company_id', companyId) .maybeSingle() @@ -5378,6 +5412,16 @@ export const tools: McpTool[] = [ if (error) throw new Error(`Database error: ${error.message}`) if (!current) throw new Error('Customer not found.') + // Same guard as gnubok_create_customer and the REST PATCH route: only + // individual rows get their identifiers masked on read (GDPR art. + // 5.1 c), so a personnummer on a business customer is refused. Checked + // against the type the row will END UP with, so a simultaneous type + // change cannot smuggle one through. + const effectiveCustomerType = (parsed.data.changes.customer_type ?? current.customer_type) as string + if (personalNumber && effectiveCustomerType !== 'individual') { + throw new Error('personal_number is only allowed for customer_type "individual".') + } + const currentPreview = { customer_id: current.id, name: current.name, @@ -5396,6 +5440,19 @@ export const tools: McpTool[] = [ language: current.language ?? 'sv', default_payment_terms: current.default_payment_terms, notes: current.notes ?? null, + // The stored value is ciphertext; the preview (and the approval UI + // that renders it) only ever sees the masked form. + personal_number_masked: maskStoredCustomerPersonalNumber(current.personal_number), + } + + // The preview never carries the ciphertext either: a personal_number + // change is shown as its masked form (or null when clearing). + const { personal_number_encrypted: _stagedCiphertext, ...visibleChanges } = parsed.data.changes + const previewChanges: Record = { ...visibleChanges } + if (parsed.data.changes.personal_number_encrypted !== undefined) { + previewChanges.personal_number_masked = personalNumber + ? maskCustomerPersonalNumber(personalNumber) + : null } return stagePendingOperation( @@ -5407,14 +5464,22 @@ export const tools: McpTool[] = [ parsed.data, { current: currentPreview, - changes: parsed.data.changes, - proposed: { ...currentPreview, ...parsed.data.changes }, + changes: previewChanges, + proposed: { ...currentPreview, ...previewChanges }, }, actor, undefined, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, + // The ciphertext in params has a random IV, so params differ on + // every retry when a personnummer is staged; hash the masked + // preview instead (same reasoning as gnubok_create_customer). Only + // set for personnummer-bearing updates so every other update keeps + // its previous hash identity. + ...(parsed.data.changes.personal_number_encrypted !== undefined + ? { idempotencyParams: { customer_id: parsed.data.customer_id, changes: previewChanges } } + : {}), }, ) }, diff --git a/lib/pending-operations/__tests__/customer-executor.test.ts b/lib/pending-operations/__tests__/customer-executor.test.ts index cd1f7905..aa9a5b81 100644 --- a/lib/pending-operations/__tests__/customer-executor.test.ts +++ b/lib/pending-operations/__tests__/customer-executor.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { PendingOperation } from '@/types' import { createQueuedMockSupabase } from '@/tests/helpers' +import { encryptPersonnummer } from '@/lib/salary/personnummer' import { commitPendingOperation } from '../commit' import { validateVatNumber } from '@/lib/vat/vies-client' @@ -250,3 +251,179 @@ describe('commitPendingOperation: update_customer', () => { expect(supabase.from).toHaveBeenCalledTimes(2) }) }) + +// ── personal_number (#1876) ─────────────────────────────────────────── +// +// Synthetic personnummer, never a real one. Staging encrypts, so the +// executor only ever sees personal_number_encrypted: ciphertext sets the +// column, explicit null clears it, absent leaves it untouched. +const PERSONAL_NUMBER = '19900101-1234' + +function individualRow(overrides: Record = {}) { + return { + id: CUSTOMER_ID, + name: 'Anna Andersson', + customer_type: 'individual', + customer_number: null, + email: null, + phone: null, + address_line1: null, + address_line2: null, + postal_code: null, + city: null, + country: 'Sweden', + org_number: null, + vat_number: null, + vat_number_validated: false, + language: 'sv', + default_payment_terms: 30, + notes: null, + personal_number: null, + ...overrides, + } +} + +describe('commitPendingOperation: update_customer personal_number', () => { + it('stores the staged ciphertext as the customer personal_number and returns only the mask', async () => { + const encrypted = encryptPersonnummer(PERSONAL_NUMBER) + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-customer-1' } }) // CAS claim + enqueue({ data: { customer_type: 'individual' } }) // current read + enqueue({ data: individualRow({ personal_number: encrypted }) }) // update returning + enqueue({ data: null }) // dispatcher update + + const result = await commitPendingOperation( + supabase as never, + 'user-1', + 'company-1', + makePendingOp({ + customer_id: CUSTOMER_ID, + changes: { personal_number_encrypted: encrypted }, + }), + ) + + expect(result.status).toBe('committed') + const updatePayload = findCall('customers', 'update')?.[0] as Record + expect(updatePayload).toMatchObject({ personal_number: encrypted }) + expect(updatePayload).not.toHaveProperty('personal_number_encrypted') + expect(result.data).toMatchObject({ + customer_id: CUSTOMER_ID, + personal_number_masked: '********-1234', + }) + // result_data is persisted and rendered in approval UIs: never the + // plaintext, never the raw ciphertext. + const serialized = JSON.stringify(result) + expect(serialized).not.toContain(PERSONAL_NUMBER) + expect(serialized).not.toContain(encrypted) + }) + + it('clears the stored personnummer on explicit null', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-customer-1' } }) + enqueue({ data: { customer_type: 'individual' } }) + enqueue({ data: individualRow() }) + enqueue({ data: null }) + + const result = await commitPendingOperation( + supabase as never, + 'user-1', + 'company-1', + makePendingOp({ + customer_id: CUSTOMER_ID, + changes: { personal_number_encrypted: null }, + }), + ) + + expect(result.status).toBe('committed') + const updatePayload = findCall('customers', 'update')?.[0] as Record + expect(updatePayload).toHaveProperty('personal_number', null) + expect(result.data).toMatchObject({ personal_number_masked: null }) + }) + + it('leaves the stored personnummer untouched when the field is absent', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-customer-1' } }) + enqueue({ data: { customer_type: 'individual' } }) + enqueue({ data: individualRow({ city: 'New City' }) }) + enqueue({ data: null }) + + const result = await commitPendingOperation( + supabase as never, + 'user-1', + 'company-1', + makePendingOp({ + customer_id: CUSTOMER_ID, + changes: { city: 'New City' }, + }), + ) + + expect(result.status).toBe('committed') + const updatePayload = findCall('customers', 'update')?.[0] as Record + expect(updatePayload).not.toHaveProperty('personal_number') + }) + + it('refuses a staged personnummer on a business customer without writing', async () => { + const encrypted = encryptPersonnummer(PERSONAL_NUMBER) + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-customer-1' } }) + enqueue({ data: { customer_type: 'swedish_business' } }) + enqueue({ data: null }) // dispatcher's reject update + + const result = await commitPendingOperation( + supabase as never, + 'user-1', + 'company-1', + makePendingOp({ + customer_id: CUSTOMER_ID, + changes: { personal_number_encrypted: encrypted }, + }), + ) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/individual/) + expect(findCall('customers', 'update')).toBeUndefined() + }) + + it('rejects a tampered plaintext personal_number key in changes', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-customer-1' } }) + enqueue({ data: null }) + + const result = await commitPendingOperation( + supabase as never, + 'user-1', + 'company-1', + makePendingOp({ + customer_id: CUSTOMER_ID, + changes: { personal_number: PERSONAL_NUMBER }, + }), + ) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/unrecognized key/i) + expect(findCall('customers', 'update')).toBeUndefined() + }) + + it('rejects a tampered plaintext value under personal_number_encrypted', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-customer-1' } }) + enqueue({ data: null }) + + const result = await commitPendingOperation( + supabase as never, + 'user-1', + 'company-1', + makePendingOp({ + customer_id: CUSTOMER_ID, + changes: { personal_number_encrypted: PERSONAL_NUMBER }, + }), + ) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/encrypted personal number/i) + expect(findCall('customers', 'update')).toBeUndefined() + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index b5cc1ead..87b69f5b 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -33,7 +33,10 @@ import { normalizeReroutedPersonalNumber, orgNumberHoldsPersonalNumber, } from '@/lib/customers/personal-number-shape' -import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number' +import { + encryptCustomerPersonalNumber, + maskStoredCustomerPersonalNumber, +} from '@/lib/customers/protect-personal-number' import { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms' import { normalizeVatRateToDecimal, @@ -483,11 +486,31 @@ async function commitUpdateCustomer( if (currentError) return { error: currentError.message, status: 500 } if (!current) return { error: 'Customer not found', status: 404 } - const updateData: Record = { ...changes } + // personal_number never travels in plaintext: staging validated the input + // and stored AES-256-GCM ciphertext under personal_number_encrypted (see + // CustomerChangesSchema). Map it onto the customers.personal_number column + // with the REST PATCH semantics: ciphertext sets the value, explicit null + // clears it, absent leaves the stored value untouched (a masked echo was + // already dropped at staging and never reaches this executor). + const { personal_number_encrypted: personalNumberEncrypted, ...columnChanges } = changes + const updateData: Record = { ...columnChanges } if (changes.customer_number !== undefined) { updateData.customer_number = changes.customer_number || null } const effectiveType = changes.customer_type ?? current.customer_type + if (personalNumberEncrypted !== undefined) { + // Same guard as staging and the REST PATCH route: only individual rows + // get their identifiers masked on read (GDPR art. 5.1 c), so a + // personnummer on a business customer is refused, not stored. Re-checked + // here so a tampered pending_operations row cannot slip past it. + if (personalNumberEncrypted !== null && effectiveType !== 'individual') { + return { + error: 'personal_number is only allowed for customer_type "individual"', + status: 400, + } + } + updateData.personal_number = personalNumberEncrypted + } if (changes.customer_type !== undefined && effectiveType !== 'individual') { updateData.personal_number = null } @@ -518,7 +541,7 @@ async function commitUpdateCustomer( .update(updateData) .eq('id', customerId) .eq('company_id', companyId) - .select('id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, language, default_payment_terms, notes') + .select('id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, language, default_payment_terms, notes, personal_number') .maybeSingle() if (error) { @@ -548,6 +571,9 @@ async function commitUpdateCustomer( language: data.language ?? 'sv', default_payment_terms: data.default_payment_terms, notes: data.notes ?? null, + // Never the stored ciphertext, and never plaintext: result_data is + // persisted on the pending operation and rendered in approval UIs. + personal_number_masked: maskStoredCustomerPersonalNumber(data.personal_number), }, } } diff --git a/lib/pending-operations/schemas/__tests__/customer.test.ts b/lib/pending-operations/schemas/__tests__/customer.test.ts new file mode 100644 index 00000000..fb543913 --- /dev/null +++ b/lib/pending-operations/schemas/__tests__/customer.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { encryptPersonnummer } from '@/lib/salary/personnummer' +import { UpdateCustomerParamsSchema } from '../customer' + +const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111' + +const wrap = (changes: Record) => ({ + customer_id: CUSTOMER_ID, + changes, +}) + +// Synthetic personnummer, never a real one. +const PERSONAL_NUMBER = '19900101-1234' + +describe('UpdateCustomerParamsSchema: personal_number_encrypted (#1876)', () => { + it('accepts AES-256-GCM ciphertext', () => { + const encrypted = encryptPersonnummer(PERSONAL_NUMBER) + const parsed = UpdateCustomerParamsSchema.parse( + wrap({ personal_number_encrypted: encrypted }), + ) + expect(parsed.changes.personal_number_encrypted).toBe(encrypted) + }) + + it('accepts explicit null (clears the stored value at commit)', () => { + const parsed = UpdateCustomerParamsSchema.parse( + wrap({ personal_number_encrypted: null }), + ) + expect(parsed.changes.personal_number_encrypted).toBeNull() + }) + + it('rejects a plaintext personnummer under personal_number_encrypted', () => { + expect(() => + UpdateCustomerParamsSchema.parse(wrap({ personal_number_encrypted: PERSONAL_NUMBER })), + ).toThrow(/encrypted personal number/i) + }) + + it('rejects the plaintext personal_number key (strict schema)', () => { + expect(() => + UpdateCustomerParamsSchema.parse(wrap({ personal_number: PERSONAL_NUMBER })), + ).toThrow(/unrecognized key/i) + }) + + it('still rejects unknown fields', () => { + expect(() => + UpdateCustomerParamsSchema.parse(wrap({ company_id: 'other-company' })), + ).toThrow(/unrecognized key/i) + }) + + it('still requires at least one changed field', () => { + expect(() => UpdateCustomerParamsSchema.parse(wrap({}))).toThrow(/at least one/i) + }) +}) diff --git a/lib/pending-operations/schemas/customer.ts b/lib/pending-operations/schemas/customer.ts index a804cdfd..90785b5a 100644 --- a/lib/pending-operations/schemas/customer.ts +++ b/lib/pending-operations/schemas/customer.ts @@ -18,6 +18,19 @@ const CustomerChangesSchema = z language: UpdateCustomerSchema.shape.language, default_payment_terms: UpdateCustomerSchema.shape.default_payment_terms, notes: UpdateCustomerSchema.shape.notes, + // The personnummer, already encrypted at staging time: the MCP tool + // validates the plaintext (REST semantics: a masked echo is dropped + // before it gets here) and stores only AES-256-GCM ciphertext, because + // staging-pii-guard.ts forbids the plaintext personal_number key in + // pending_operations payloads. Ciphertext shape mirrors + // customers_personal_number_check (20260726110000). At commit: string + // sets the stored value, explicit null clears it, absent leaves it + // untouched. + personal_number_encrypted: z + .string() + .regex(/^[0-9a-f]{76,255}$/, 'Invalid encrypted personal number') + .nullable() + .optional(), }) .strict() .superRefine((changes, ctx) => {