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,169 @@
|
||||
/**
|
||||
* One-off repair: move an individual customer's personnummer out of
|
||||
* customers.org_number into customers.personal_number (encrypted).
|
||||
*
|
||||
* WHY: until 2026-08-21 the MCP gnubok_create_customer tool had no
|
||||
* personal_number input, the v1 REST docs said org_number was "accepted as
|
||||
* input" for individuals, and nothing masks org_number. So a customer_type=
|
||||
* 'individual' row could carry its personnummer in org_number, where the
|
||||
* web customer list, the v1 detail endpoint and the MCP customer list showed
|
||||
* it raw (GDPR art. 5.1 c). The code fix moves such a value into
|
||||
* personal_number on every write path; this repairs the rows already in the
|
||||
* DB. Run AFTER the code fix is deployed (the read paths mask these rows in
|
||||
* the meantime, but the data should not stay there).
|
||||
*
|
||||
* Selection: customer_type='individual' AND org_number has Swedish personnummer
|
||||
* shape (lib/customers/personal-number-shape.ts). Legal-entity org numbers
|
||||
* never match (month position >= 20), so an individual carrying a real
|
||||
* organisationsnummer is left alone.
|
||||
*
|
||||
* Per row:
|
||||
* - personal_number NULL -> personal_number = encrypt(org_number), org_number = NULL
|
||||
* - personal_number already set -> org_number = NULL only (the stored
|
||||
* personnummer wins; the duplicate is reported)
|
||||
*
|
||||
* Idempotent: a repaired row no longer matches the selection, and every
|
||||
* update is guarded on the exact org_number value so a re-run or a concurrent
|
||||
* edit can never double-apply. Safe to re-run.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/repair-customer-personal-number-in-org-number.ts # dry run (read-only)
|
||||
* npx tsx scripts/repair-customer-personal-number-in-org-number.ts --confirm # performs the writes
|
||||
* add --company <uuid> to limit either mode to one company
|
||||
*
|
||||
* Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY from .env.local;
|
||||
* --confirm additionally needs PERSONNUMMER_ENCRYPTION_KEY (the production
|
||||
* key, same one the app encrypts with). Treat .env.local as pointing at
|
||||
* PRODUCTION: the dry run is read-only; --confirm mutates PII. Never prints a
|
||||
* personnummer; rows are reported by id and company.
|
||||
*/
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { config as dotenv } from 'dotenv'
|
||||
import { resolve } from 'node:path'
|
||||
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
|
||||
import {
|
||||
normalizeReroutedPersonalNumber,
|
||||
orgNumberHoldsPersonalNumber,
|
||||
} from '@/lib/customers/personal-number-shape'
|
||||
|
||||
dotenv({ path: resolve(process.cwd(), '.env.local') })
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
|
||||
process.exit(1)
|
||||
}
|
||||
const CONFIRM = process.argv.includes('--confirm')
|
||||
// Refuse to WRITE without the real key: encrypting with the dev fallback key
|
||||
// would make the values unreadable in production. The dry run encrypts
|
||||
// nothing, so it runs without it (the key lives in the Vercel env, not in
|
||||
// every local .env.local).
|
||||
if (CONFIRM && !process.env.PERSONNUMMER_ENCRYPTION_KEY) {
|
||||
console.error(
|
||||
'Missing PERSONNUMMER_ENCRYPTION_KEY. Refusing to write so rows are not encrypted with the dev fallback key '
|
||||
+ '(vercel env pull, or set it for this run).',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const companyFlag = process.argv.indexOf('--company')
|
||||
const ONLY_COMPANY = companyFlag >= 0 ? process.argv[companyFlag + 1] : undefined
|
||||
|
||||
const sb = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } })
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
company_id: string
|
||||
customer_type: string
|
||||
org_number: string | null
|
||||
personal_number: string | null
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const host = new URL(SUPABASE_URL!).host
|
||||
console.log(
|
||||
`Target: ${host} mode: ${CONFIRM ? 'WRITE (--confirm)' : 'DRY RUN (read-only)'}`
|
||||
+ (ONLY_COMPANY ? ` company: ${ONLY_COMPANY}` : ''),
|
||||
)
|
||||
|
||||
// Page on the PK: PostgREST caps an unranged select at 1000 rows.
|
||||
const PAGE = 1000
|
||||
const candidates: Row[] = []
|
||||
for (let from = 0; ; from += PAGE) {
|
||||
let query = sb
|
||||
.from('customers')
|
||||
.select('id, company_id, customer_type, org_number, personal_number')
|
||||
.eq('customer_type', 'individual')
|
||||
.not('org_number', 'is', null)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, from + PAGE - 1)
|
||||
if (ONLY_COMPANY) query = query.eq('company_id', ONLY_COMPANY)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`select customers: ${error.message}`)
|
||||
const rows = (data ?? []) as Row[]
|
||||
candidates.push(...rows)
|
||||
if (rows.length < PAGE) break
|
||||
}
|
||||
|
||||
const affected = candidates.filter((r) => orgNumberHoldsPersonalNumber(r.customer_type, r.org_number))
|
||||
const toMove = affected.filter((r) => !r.personal_number)
|
||||
const toClear = affected.filter((r) => r.personal_number)
|
||||
const companies = new Set(affected.map((r) => r.company_id))
|
||||
|
||||
console.log(
|
||||
`Individual rows with org_number: ${candidates.length}; personnummer-shaped: ${affected.length} `
|
||||
+ `across ${companies.size} companies (move: ${toMove.length}, clear-duplicate: ${toClear.length}).`,
|
||||
)
|
||||
for (const r of affected) {
|
||||
console.log(` ${r.personal_number ? 'CLEAR' : 'MOVE '} customer ${r.id} company ${r.company_id}`)
|
||||
}
|
||||
|
||||
if (!CONFIRM) {
|
||||
console.log('Dry run: no writes performed. Re-run with --confirm to apply.')
|
||||
return
|
||||
}
|
||||
|
||||
let moved = 0
|
||||
let cleared = 0
|
||||
let skipped = 0
|
||||
for (const r of affected) {
|
||||
// Guard on the exact current value: a concurrent edit or a re-run cannot
|
||||
// double-apply, and a row that changed underneath is skipped, not clobbered.
|
||||
// Two literal payloads rather than one built at runtime, so the
|
||||
// no-phantom-columns scanner can resolve both.
|
||||
const { data, error } = r.personal_number
|
||||
? await sb
|
||||
.from('customers')
|
||||
.update({ org_number: null })
|
||||
.eq('id', r.id)
|
||||
.eq('company_id', r.company_id)
|
||||
.eq('org_number', r.org_number!)
|
||||
.select('id')
|
||||
: await sb
|
||||
.from('customers')
|
||||
.update({
|
||||
org_number: null,
|
||||
personal_number: encryptCustomerPersonalNumber(normalizeReroutedPersonalNumber(r.org_number!)),
|
||||
})
|
||||
.eq('id', r.id)
|
||||
.eq('company_id', r.company_id)
|
||||
.eq('org_number', r.org_number!)
|
||||
.select('id')
|
||||
if (error) {
|
||||
console.error(` FAILED customer ${r.id}: ${error.message}`)
|
||||
continue
|
||||
}
|
||||
if (!data || data.length === 0) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
if (r.personal_number) cleared += 1
|
||||
else moved += 1
|
||||
}
|
||||
console.log(`Done. moved: ${moved}, cleared duplicate org_number: ${cleared}, skipped (changed underneath): ${skipped}.`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user