* 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>
79 lines
3.2 KiB
TypeScript
79 lines
3.2 KiB
TypeScript
/**
|
|
* Shape detection for Swedish personal identity numbers submitted where an
|
|
* organisationsnummer belongs.
|
|
*
|
|
* A legal-entity organisationsnummer always carries 20 or higher in its
|
|
* "month" position (SFS 1974:174 2 §), while a personnummer has a real
|
|
* calendar month 01-12 (samordningsnummer offsets the day by 60 instead).
|
|
* That makes the two distinguishable without a checksum: any 10- or
|
|
* 12-digit value with a month of 01-12 and a plausible day is a personal
|
|
* identity number, never a company.
|
|
*
|
|
* Used to stop a personnummer from being stored as a business org_number,
|
|
* where nothing masks it: list responses only mask identifiers on
|
|
* customer_type='individual' rows (GDPR art. 5.1 c data minimisation).
|
|
*
|
|
* Deliberately crypto-free so the client form, the Zod schemas and the
|
|
* server routes can all share it, same as mask-personal-number.ts.
|
|
*/
|
|
export function looksLikeSwedishPersonalNumber(value: string): boolean {
|
|
const digits = value.replace(/[\s+-]/g, '')
|
|
if (!/^(\d{10}|\d{12})$/.test(digits)) return false
|
|
|
|
if (digits.length === 12) {
|
|
// 12-digit organisationsnummer are written with a '16' century prefix
|
|
// (Skatteverket convention); personnummer centuries are 18/19/20.
|
|
const century = digits.slice(0, 2)
|
|
if (century !== '18' && century !== '19' && century !== '20') return false
|
|
}
|
|
|
|
const body = digits.length === 12 ? digits.slice(2) : digits
|
|
const month = parseInt(body.slice(2, 4), 10)
|
|
const day = parseInt(body.slice(4, 6), 10)
|
|
|
|
if (month < 1 || month > 12) return false
|
|
|
|
// Day 1-31 for a personnummer, 61-91 for a samordningsnummer (+60 offset).
|
|
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, '')
|
|
}
|