/** * Sanity-check the `LOG_USER_ID_SALT` env var early in bootstrap so * a missing or obviously weak value fails fast instead of producing * predictable hashes at runtime. * * Wired in `main.ts` alongside the other `assertX()` validators — * same pre-flight family per ADR-0018 §"BFF env-var loading". * * `LOG_USER_ID_SALT` is the per-environment salt fed into SHA-256 * to pseudonymise user ids before they land in audit rows * (`actor_id_hash`, per ADR-0013 §"Schema") and in Pino app log * lines (`user_id_hash`, per ADR-0012 §"User id hashing"). The * same value must be in scope on both writers so the two streams * join on the hash. * * Returns the salt as a string so callers (`HashUserIdService`) * can fold it into the hash without re-reading the env. */ const PLACEHOLDER = 'replace_with_32_random_bytes_base64url'; const MIN_ENTROPY_BYTES = 32; export function assertLogUserIdSalt(): string { const raw = process.env['LOG_USER_ID_SALT']; if (!raw || raw === '') { throw new Error( `LOG_USER_ID_SALT is not set. Generate one with ` + `"node -e \\"console.log(require('crypto').randomBytes(32).toString('base64url'))\\"" ` + `and put it in apps/portal-bff/.env.`, ); } if (raw === PLACEHOLDER) { throw new Error( `LOG_USER_ID_SALT is still set to the .env.example placeholder ` + `("${PLACEHOLDER}"). Replace with a real random value.`, ); } // Accept base64url-encoded input (the documented generation // recipe). Anything below 32 bytes of decoded entropy is weaker // than the project's AES-256 key family — reject early. let decoded: Buffer; try { decoded = Buffer.from(raw, 'base64url'); } catch { throw new Error(`LOG_USER_ID_SALT must be a base64url-encoded string. Got: ${truncate(raw)}`); } if (decoded.length < MIN_ENTROPY_BYTES) { throw new Error( `LOG_USER_ID_SALT decodes to ${decoded.length} bytes, ` + `below the ${MIN_ENTROPY_BYTES}-byte minimum (≈ 256 bits of entropy). ` + `Generate a longer value.`, ); } return raw; } function truncate(s: string): string { return s.length > 16 ? `${s.slice(0, 16)}…` : s; }