gnubok_create_customer takes a personnummer (encrypted before approval) but gnubok_update_customer did not, so an existing customer whose personnummer sat in the org-number field could not be corrected via MCP. The REST PATCH already supports it; this closes the MCP/pending-operations gap across its three layers: - tool inputSchema: personal_number (string or null) on the strict whitelist. The tool validates the plaintext before any DB read and mirrors the REST PATCH semantics: masked echo (********-1234 or ********-????) = leave unchanged, explicit null = clear, absent = untouched. Setting is refused unless the row ends up as an individual (GDPR art. 5.1 c), including via a simultaneous type change. - CustomerChangesSchema: personal_number_encrypted (nullable, ciphertext shape per customers_personal_number_check 20260726110000). The plaintext key stays forbidden by .strict() and staging-pii-guard. - update executor: maps the staged ciphertext onto customers .personal_number (set/clear/leave), re-checks the individual-only rule against a tampered row, and returns only personal_number_masked. PII handling: the personnummer is encrypted at staging time (AES-256-GCM, same path as create); pending_operations params carry only the ciphertext and the approval preview only the masked form. Idempotency hashing switches to the masked preview for personnummer-bearing updates (random-IV ciphertext would break retries); other updates keep their previous hash identity. catalogVisibility stays 'search': tools/list is at its 59.95K token ceiling with zero headroom (see DECISIONS.md). Fixes #1876 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d80103a2f5
commit
77cacdcf34
@@ -1194,6 +1194,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, Record<string, unknown>>
|
||||
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<string, unknown> }
|
||||
preview_data: {
|
||||
current: Record<string, unknown>
|
||||
changes: Record<string, unknown>
|
||||
proposed: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
}
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown> = { ...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 } }
|
||||
: {}),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown> = { ...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<string, unknown> = { ...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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => ({
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user