1a41119682
* perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline
Two chunks rode along in the first-load JS of almost every dashboard route:
the full BAS 2026 chart (315 KB uncompressed, in 81 route manifests) and
the browser polyfill for Node's crypto/vm/Buffer (327 KB, in 26 routes
incl. login and register). Neither was needed on first paint; both got
there through static imports of helpers that happen to live next to code
that needs the data or the builtin.
Node polyfill (4 pure splits, behaviour unchanged, re-exported from the
original modules for server callers):
- lib/auth/bankid-flags.ts: isBankIdEnabled (login, register, security
settings imported it from bankid.ts, which imports crypto).
- lib/import/bank-file/formats.ts: the format registry + detection (the
import history imported getFormat from parser.ts, which hashes).
- lib/salary/personnummer-format.ts: parsing/validation/formatting (the
employee forms reached the encrypting personnummer.ts via tax-column).
- lib/auth/api-key-scopes.ts: scope catalogue, groups, tool map, helpers
(the API key panel imported STAGING_SCOPES from the key generator).
BAS chart:
- lib/bookkeeping/bas-lazy.ts + use-bas-reference.ts: the chart becomes a
dynamic import, fetched once per session after first paint; components
that show BAS names/descriptions call useBasReference() and re-render
when it lands. Until then (and on the server) only the hardcoded
account-descriptions answer, so SSR and hydration agree.
- lib/bookkeeping/bas-labels.ts: class/group labels out of bas-reference.ts
(account-descriptions needed a label and paid for the whole chart).
- lib/bookkeeping/bas-account-numbers.ts (generated, ~11 KB) +
scripts/generate-bas-account-numbers.ts (--check) + parity test:
isStandardBASAccountNumber for AddAccountDialog/ChartOfAccountsManager.
- lib/bookkeeping/account-classifier-{heuristic,client}.ts: the BAS-aligned
heuristic shared by the server classifier and a client variant that uses
the lazy chart.
- lib/bookkeeping/invoice-accounts.ts: INVOICE_FX_RATE_MISSING,
InvoiceFxRateMissingError, getRevenueAccount, getOutputVatAccount out of
invoice-entries.ts, whose engine import pulled account-backfill and the
chart into SendInvoiceDialog/PaymentBookingDialog.
- CorrectOpeningBalanceDialog re-seeds names when the chart lands;
OpeningBalanceRowEditor builds its Fuse indexes lazily; the
ChartOfAccountsManager BAS-katalog tab awaits the chunk.
Tooling:
- scripts/perf/client-import-closure.mjs: static import closure of every
'use client' module with the shortest chain to a target (file or bare
specifier); found every path above without a build.
- scripts/checks/client-node-builtin.mjs wired into check:guards: a client
module reaching a Node builtin is a hard failure (0 today).
Left as is: invoices/[id], its credit page and SendInvoiceDialog still
reach the chart through lib/invoices/issue-credit-note -> invoice-entries
-> engine -> account-backfill; splitting the engine is out of scope here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(perf): unambiguous import-edge regex in the closure walker (CodeQL js/redos)
One quantifier per span: a greedy [^'"]* up to the specifier quote, which it
cannot cross, so a run of whitespace has a single parse. Same edges as
before (multi-line named imports, re-exports, side-effect imports; type-only
imports still skipped).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
/**
|
|
* BankID authentication helpers.
|
|
*
|
|
* BankID is only available on the hosted deployment (requires TIC Identity API).
|
|
* Self-hosted deployments never show the BankID option.
|
|
*/
|
|
|
|
import crypto from 'crypto'
|
|
|
|
const ALGORITHM = 'aes-256-gcm'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Feature flag
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// isBankIdEnabled lives in ./bankid-flags (no Node imports) so the login,
|
|
// register and security-settings client components can read the flag
|
|
// without pulling this module's `crypto` import, and with it the browser
|
|
// crypto polyfill, into their bundles. Re-exported here for server callers.
|
|
export { isBankIdEnabled } from './bankid-flags'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Personnummer hashing (for lookup)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** SHA-256 hash of a personnummer for fast DB lookup. */
|
|
export function hashPersonalNumber(personalNumber: string): string {
|
|
return crypto.createHash('sha256').update(personalNumber).digest('hex')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Personnummer encryption (for display in settings)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getEncryptionKey(): Buffer {
|
|
const key = process.env.BANKID_ENCRYPTION_KEY
|
|
if (!key) throw new Error('BANKID_ENCRYPTION_KEY is required for BankID operations')
|
|
return Buffer.from(key, 'hex')
|
|
}
|
|
|
|
/** AES-256-GCM encrypt a personnummer for storage. */
|
|
export function encryptPersonalNumber(personalNumber: string): Buffer {
|
|
const key = getEncryptionKey()
|
|
const iv = crypto.randomBytes(12)
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
|
|
|
const encrypted = Buffer.concat([cipher.update(personalNumber, 'utf8'), cipher.final()])
|
|
const tag = cipher.getAuthTag()
|
|
|
|
// Format: iv (12) + tag (16) + ciphertext
|
|
return Buffer.concat([iv, tag, encrypted])
|
|
}
|
|
|
|
/** AES-256-GCM decrypt a stored personnummer. */
|
|
export function decryptPersonalNumber(data: Buffer): string {
|
|
const key = getEncryptionKey()
|
|
|
|
const iv = data.subarray(0, 12)
|
|
const tag = data.subarray(12, 28)
|
|
const encrypted = data.subarray(28)
|
|
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
|
decipher.setAuthTag(tag)
|
|
|
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Storage codec (bankid_identities.personal_number_enc)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Encrypt a personnummer and encode it for a PostgREST bytea insert.
|
|
*
|
|
* Passing a raw Buffer to supabase-js serializes it as JSON
|
|
* ('{"type":"Buffer","data":[...]}'), storing that literal text in the
|
|
* column instead of the bytes. PostgREST's bytea input format is a
|
|
* '\x'-prefixed hex string, which is what this returns.
|
|
*/
|
|
export function encryptPersonalNumberForStorage(personalNumber: string): string {
|
|
return '\\x' + encryptPersonalNumber(personalNumber).toString('hex')
|
|
}
|
|
|
|
/** Legacy shape written by supabase-js Buffer serialization before 2026-07. */
|
|
type SerializedBuffer = { type: 'Buffer'; data: number[] }
|
|
|
|
function isSerializedBuffer(value: unknown): value is SerializedBuffer {
|
|
return (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
(value as SerializedBuffer).type === 'Buffer' &&
|
|
Array.isArray((value as SerializedBuffer).data)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Decode and decrypt a personal_number_enc value as read back through
|
|
* PostgREST (a '\x'-prefixed hex string) or a raw Buffer.
|
|
*
|
|
* Tolerates rows written before migration 20260727170000, where the column
|
|
* holds the UTF-8 text of a JSON-serialized Buffer rather than the raw
|
|
* iv|tag|ciphertext bytes.
|
|
*/
|
|
export function decryptStoredPersonalNumber(stored: string | Buffer | SerializedBuffer): string {
|
|
let raw: Buffer
|
|
if (Buffer.isBuffer(stored)) {
|
|
raw = stored
|
|
} else if (typeof stored === 'string') {
|
|
raw = stored.startsWith('\\x')
|
|
? Buffer.from(stored.slice(2), 'hex')
|
|
: Buffer.from(stored, 'utf8')
|
|
} else if (isSerializedBuffer(stored)) {
|
|
raw = Buffer.from(stored.data)
|
|
} else {
|
|
throw new Error('Unsupported personal_number_enc value')
|
|
}
|
|
|
|
// Legacy JSON-serialized Buffer stored as text: unwrap to the real bytes.
|
|
if (raw[0] === 0x7b) {
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw.toString('utf8'))
|
|
if (isSerializedBuffer(parsed)) raw = Buffer.from(parsed.data)
|
|
} catch {
|
|
// Not JSON after all: treat as raw ciphertext.
|
|
}
|
|
}
|
|
|
|
return decryptPersonalNumber(raw)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Display helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Mask a personnummer for display: "XXXXXXXX-1234" */
|
|
export function maskPersonalNumber(personalNumber: string): string {
|
|
if (personalNumber.length < 4) return '****'
|
|
const last4 = personalNumber.slice(-4)
|
|
const masked = personalNumber.length === 12 ? 'XXXXXXXX' : 'XXXXXX'
|
|
return `${masked}-${last4}`
|
|
}
|