fix(customers): personnummer via MCP lands in personal_number, masked everywhere; MCP payment terms follow settings (#1788)
* fix(customers): personnummer on the MCP path lands in personal_number, masked everywhere; MCP payment terms follow settings Follow-up to #1724 (Discord kalletoxic): the fix reached the web form and the v1 REST API, but not the MCP path, and the web customer list still showed a personnummer raw when it sat in org_number. Personnummer (MCP + every write path): - gnubok_create_customer gets a personal_number input. Until now it had none, so an agent creating a private person either dropped the number or put it in org_number, which nothing masks. Encrypted at staging (personal_number_encrypted + personal_number_masked; personal_number is now a forbidden staging key in staging-pii-guard), the approval preview shows ********-1234, commitCreateCustomer stores the ciphertext as-is. Idempotency hashes the masked preview (new StageOptions.idempotencyParams) because the random-IV ciphertext would make identical retries look like payload changes. - A personnummer-shaped org_number on customer_type=individual is the personnummer in the wrong field: it is moved into personal_number (encrypted) and org_number cleared, on CreateCustomerSchema (web POST, v1 POST, v1 bulk), both PATCH routes, MCP staging, and commitCreateCustomer for in-flight ops. Only a DIFFERENT personnummer next to personal_number is refused (new CUSTOMER_PERSONAL_NUMBER_CONFLICT). The business-type guard from #1724 is unchanged and now also fires at MCP staging, so the user never approves an operation that fails at commit. - Read side: the web customer list and gnubok_list_customers mask a legacy individual row's org_number personnummer instead of showing it raw; list_customers exposes personal_number_masked and never the ciphertext. - scripts/repair-customer-personal-number-in-org-number.ts moves the existing rows (dry run: 134 rows across 10 companies on prod); run by hand with --confirm after deploy. - customer-onboarding skill: EF customers follow the #1724 decision (individual + personal_number); ROT/RUT section names the real field. Payment terms (MCP): - gnubok_create_customer staged `payment_terms || 30`, so resolveDefaultPaymentTerms at commit always saw 30 and the company's invoice_default_days never reached MCP customers. Resolved at staging now, so the preview shows the value the row will get. tools/list payload ceiling 59.75K to 59.85K (descriptions trimmed first, rationale in payload-size.bench.test.ts). apiskill regenerated; no migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbLqn9bgZ9NJ5qnZMeC1Bk * fix(scripts): literal update payloads in the personnummer repair script The no-phantom-columns scanner counts a runtime-built update payload as unresolvable and the ceiling (379) had no headroom; two literal payloads keep the guard able to resolve both branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbLqn9bgZ9NJ5qnZMeC1Bk --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
a5c8f55127
commit
13b69a2056
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PERSONAL_NUMBER_PLAINTEXT_RE,
|
||||
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
|
||||
customerListIdentifier,
|
||||
} from '@/lib/customers/mask-personal-number'
|
||||
|
||||
// Synthetic personnummer throughout, never a real one.
|
||||
describe('customerListIdentifier', () => {
|
||||
it('shows org_number for business rows', () => {
|
||||
expect(customerListIdentifier({ customer_type: 'swedish_business', org_number: '556677-8899' })).toBe('556677-8899')
|
||||
expect(customerListIdentifier({ customer_type: 'swedish_business', org_number: null })).toBe('')
|
||||
})
|
||||
|
||||
it('shows the masked personal_number for individual rows, passing an API mask through', () => {
|
||||
expect(customerListIdentifier({ customer_type: 'individual', personal_number: '********-1234' })).toBe('********-1234')
|
||||
expect(customerListIdentifier({ customer_type: 'individual', personal_number: '19900101-1234' })).toBe('********-1234')
|
||||
expect(
|
||||
customerListIdentifier({ customer_type: 'individual', personal_number: UNDECRYPTABLE_PERSONAL_NUMBER_MASK }),
|
||||
).toBe(UNDECRYPTABLE_PERSONAL_NUMBER_MASK)
|
||||
})
|
||||
|
||||
it('masks a legacy individual row that still carries its personnummer in org_number', () => {
|
||||
expect(customerListIdentifier({ customer_type: 'individual', org_number: '19900101-1234', personal_number: null })).toBe(
|
||||
'********-1234',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a non-personnummer org_number on an individual visible', () => {
|
||||
expect(customerListIdentifier({ customer_type: 'individual', org_number: 'CHE-123.456.789' })).toBe('CHE-123.456.789')
|
||||
expect(customerListIdentifier({ customer_type: 'individual' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PERSONAL_NUMBER_PLAINTEXT_RE', () => {
|
||||
it('accepts the four written forms and nothing else', () => {
|
||||
for (const value of ['900101-1234', '900101+1234', '19900101-1234', '9001011234', '199001011234']) {
|
||||
expect(PERSONAL_NUMBER_PLAINTEXT_RE.test(value)).toBe(true)
|
||||
}
|
||||
// Shape only: a legal-entity orgnr with a dash has the same digit shape
|
||||
// and is kept out by looksLikeSwedishPersonalNumber at the call sites,
|
||||
// not by this regex.
|
||||
for (const value of ['********-1234', '********-????', '19900101 1234', 'abc', '1234']) {
|
||||
expect(PERSONAL_NUMBER_PLAINTEXT_RE.test(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
|
||||
import {
|
||||
looksLikeSwedishPersonalNumber,
|
||||
normalizeReroutedPersonalNumber,
|
||||
orgNumberHoldsPersonalNumber,
|
||||
personalNumberDigits,
|
||||
} from '@/lib/customers/personal-number-shape'
|
||||
|
||||
// Every personal-shaped fixture is synthetic, never a real person's number.
|
||||
describe('looksLikeSwedishPersonalNumber', () => {
|
||||
@@ -40,3 +45,37 @@ describe('looksLikeSwedishPersonalNumber', () => {
|
||||
expect(looksLikeSwedishPersonalNumber('************')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orgNumberHoldsPersonalNumber', () => {
|
||||
it('is true only for an individual whose org_number has personnummer shape', () => {
|
||||
expect(orgNumberHoldsPersonalNumber('individual', '19900101-1234')).toBe(true)
|
||||
expect(orgNumberHoldsPersonalNumber('individual', '9001011234')).toBe(true)
|
||||
expect(orgNumberHoldsPersonalNumber('individual', '19900101 1234')).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for business types, legal-entity org numbers, and empty values', () => {
|
||||
// A business carrying a personnummer is the other guard's job (reject),
|
||||
// not a reroute.
|
||||
expect(orgNumberHoldsPersonalNumber('swedish_business', '19900101-1234')).toBe(false)
|
||||
expect(orgNumberHoldsPersonalNumber('individual', '556677-8899')).toBe(false)
|
||||
expect(orgNumberHoldsPersonalNumber('individual', '')).toBe(false)
|
||||
expect(orgNumberHoldsPersonalNumber('individual', ' ')).toBe(false)
|
||||
expect(orgNumberHoldsPersonalNumber('individual', null)).toBe(false)
|
||||
expect(orgNumberHoldsPersonalNumber('individual', undefined)).toBe(false)
|
||||
expect(orgNumberHoldsPersonalNumber(undefined, '19900101-1234')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeReroutedPersonalNumber / personalNumberDigits', () => {
|
||||
it('drops whitespace but keeps the separator', () => {
|
||||
expect(normalizeReroutedPersonalNumber('19900101 1234')).toBe('199001011234')
|
||||
expect(normalizeReroutedPersonalNumber(' 19900101-1234 ')).toBe('19900101-1234')
|
||||
expect(normalizeReroutedPersonalNumber('900101+1234')).toBe('900101+1234')
|
||||
})
|
||||
|
||||
it('compares across written forms by digits only', () => {
|
||||
expect(personalNumberDigits('19900101-1234')).toBe('199001011234')
|
||||
expect(personalNumberDigits('19900101-1234')).toBe(personalNumberDigits('199001011234'))
|
||||
expect(personalNumberDigits('900101-1234')).not.toBe(personalNumberDigits('19900101-1234'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* half lives in lib/customers/protect-personal-number.ts, which is server-only.
|
||||
*/
|
||||
|
||||
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
|
||||
|
||||
/**
|
||||
* Placeholder used when a stored personal_number cannot be decrypted
|
||||
* (corrupted ciphertext, a value written under a different
|
||||
@@ -53,6 +55,13 @@ export function isMaskedPersonalNumber(value: unknown): boolean {
|
||||
*/
|
||||
export const PERSONAL_NUMBER_INPUT_RE = /^(?:(?:\d{6}|\d{8})[-+]?\d{4}|\*{8}-(?:\d{4}|\?{4}))$/
|
||||
|
||||
/**
|
||||
* The plaintext half alone: the four accepted written forms of a personnummer
|
||||
* (YYMMDD-XXXX, YYMMDD+XXXX, YYYYMMDD-XXXX, or the 10/12 digits). What a create
|
||||
* path accepts.
|
||||
*/
|
||||
export const PERSONAL_NUMBER_PLAINTEXT_RE = /^(?:\d{6}|\d{8})[-+]?\d{4}$/
|
||||
|
||||
/**
|
||||
* Display a personal identity number without exposing birth date or full ID.
|
||||
*
|
||||
@@ -67,3 +76,30 @@ export function maskCustomerPersonalNumber(value: string | null | undefined): st
|
||||
const last4 = value.replace(/\D/g, '').slice(-4)
|
||||
return last4.length === 4 ? `********-${last4}` : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The identifier a customer LIST may show for a row, never a raw personnummer.
|
||||
*
|
||||
* Business rows show org_number (Bolagsverket-public). Individual rows show
|
||||
* the masked personal_number; a legacy individual row that still carries its
|
||||
* personnummer in org_number (written before the write paths started moving
|
||||
* it, see lib/customers/personal-number-shape.ts) shows that value masked the
|
||||
* same way instead of raw. Callers pass rows whose personal_number is already
|
||||
* the API's masked form or a plaintext value; ciphertext must be masked
|
||||
* server-side first (maskStoredCustomerPersonalNumber).
|
||||
*/
|
||||
export function customerListIdentifier(row: {
|
||||
customer_type?: string | null
|
||||
org_number?: string | null
|
||||
personal_number?: string | null
|
||||
}): string {
|
||||
if (row.customer_type !== 'individual') {
|
||||
return row.org_number || row.personal_number || ''
|
||||
}
|
||||
if (row.personal_number) return maskCustomerPersonalNumber(row.personal_number) ?? ''
|
||||
const orgNumber = row.org_number || ''
|
||||
if (orgNumber && looksLikeSwedishPersonalNumber(orgNumber)) {
|
||||
return maskCustomerPersonalNumber(orgNumber) ?? ''
|
||||
}
|
||||
return orgNumber
|
||||
}
|
||||
|
||||
@@ -37,3 +37,42 @@ export function looksLikeSwedishPersonalNumber(value: string): boolean {
|
||||
const birthDay = day > 60 ? day - 60 : day
|
||||
return birthDay >= 1 && birthDay <= 31
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a customer row's org_number is really its personnummer: the row
|
||||
* is an individual (privatperson) and the value has personnummer shape.
|
||||
*
|
||||
* Every write path treats that combination as "personnummer submitted in the
|
||||
* wrong field": the value is moved into personal_number (encrypted, masked on
|
||||
* read) and org_number is left empty. Nothing masks org_number, so leaving it
|
||||
* there is exactly the unmasked-identifier leak the business-type guard above
|
||||
* exists to prevent; and the MCP create tool had no personal_number input at
|
||||
* all until 2026-08-21, so agents had nowhere else to put it.
|
||||
*/
|
||||
export function orgNumberHoldsPersonalNumber(
|
||||
customerType: string | null | undefined,
|
||||
orgNumber: string | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
customerType === 'individual'
|
||||
&& typeof orgNumber === 'string'
|
||||
&& orgNumber.trim() !== ''
|
||||
&& looksLikeSwedishPersonalNumber(orgNumber)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The personnummer as it should be stored once it has been lifted out of
|
||||
* org_number: separators are kept (the encrypt path accepts any of the four
|
||||
* input forms) but whitespace is dropped, because "19900101 1234" passes the
|
||||
* shape check yet fails PERSONAL_NUMBER_INPUT_RE and the legacy-plaintext
|
||||
* reveal regex.
|
||||
*/
|
||||
export function normalizeReroutedPersonalNumber(orgNumber: string): string {
|
||||
return orgNumber.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
/** Digits only, for comparing a personnummer across its written forms. */
|
||||
export function personalNumberDigits(value: string): string {
|
||||
return value.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user