Files
halobestie-clone/backend/src/services/config.service.js
Ramadhan Sjamsani 529a38ae3f feat(backend): pin server timezone to UTC with startup assertion
Belt-and-suspenders, not a bug fix: storage (timestamptz) and timer math are already tz-independent. Add SERVER_TZ env (default UTC) via getServerTimezone(); db/client.js pins the DB session timezone (reads env directly to avoid an import cycle); server.js pins process.env.TZ and asserts at boot that the DB session matches (logs [tz] or a loud warning). Keeps any future date_trunc/::date reporting deterministic and surfaces a misconfigured server early. Documented in backend/CLAUDE.md + .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:27:16 +08:00

630 lines
24 KiB
JavaScript

import bcrypt from 'bcrypt'
import crypto from 'node:crypto'
import { getDb } from '../db/client.js'
import { ExtensionTimeoutAction, UserType } from '../constants.js'
const sql = getDb()
// bcrypt cost for the per-entry static OTP. Same rationale as
// otp.service.js OTP_BCRYPT_COST — 10 keeps the verify SLA tight without
// meaningfully reducing protection (OTPs are 6 digits; cost mostly buys time
// against an offline DB-dump brute force, which the 5-min TTL already bounds).
const TEST_OTP_BYPASS_BCRYPT_COST = 10
export const getAnonymityConfig = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'`
return { anonymity_enabled: row?.value?.enabled ?? false }
}
export const setAnonymityConfig = async (enabled) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('anonymity', ${sql.json({ enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { anonymity_enabled: enabled }
}
export const getMaxCustomersPerMitra = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'max_customers_per_mitra'`
return { max_customers_per_mitra: row?.value?.value ?? 3 }
}
export const setMaxCustomersPerMitra = async (value) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('max_customers_per_mitra', ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
// Capacity changed → drop cached availability snapshot.
// Imported lazily to avoid a circular import (mitra-status.service uses config).
const { invalidateAvailabilityCache } = await import('./mitra-status.service.js')
invalidateAvailabilityCache()
return { max_customers_per_mitra: value }
}
// --- Phase 4: Support handles ---
export const getSupportHandles = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'support_handles_json'`
// Stored shape: { wa: {...}, telegram: {...} }. Fall back to a safe empty payload
// so the client renders an empty Tanya Admin sheet rather than crashing.
return row?.value ?? {
wa: { label: 'WhatsApp', deeplink: '' },
telegram: { label: 'Telegram', deeplink: '' },
}
}
export const setSupportHandles = async ({ wa, telegram }) => {
const current = await getSupportHandles()
const next = {
wa: { ...current.wa, ...(wa || {}) },
telegram: { ...current.telegram, ...(telegram || {}) },
}
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('support_handles_json', ${sql.json(next)}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return next
}
export const getExtensionTimeoutConfig = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'`
// Default 10s pairs with the auto-approve-on-timeout flow; raise this if you change the policy to auto-reject.
return { extension_timeout_seconds: row?.value?.value ?? 10 }
}
export const setExtensionTimeoutConfig = async (seconds) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('extension_timeout_seconds', ${sql.json({ value: seconds })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { extension_timeout_seconds: seconds }
}
export const getEarlyEndConfig = async () => {
const [mitraRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_mitra_enabled'`
const [customerRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_customer_enabled'`
return {
mitra_enabled: mitraRow?.value?.value ?? false,
customer_enabled: customerRow?.value?.value ?? false,
}
}
// --- Mitra reachability config ---
//
// Two separate concerns, deliberately decoupled:
// - heartbeat_cadence_seconds: how often the mitra app sends a heartbeat.
// Fixed per backend deployment via the MITRA_HEARTBEAT_CADENCE_SECONDS
// env (default 30). The mitra app reads this from /api/mitra/status and
// uses it directly as its Timer.periodic interval.
// - stale_after_seconds: how long the backend tolerates silence before
// marking a mitra offline. DB-stored, CC-tunable. Must be >= the
// heartbeat cadence (CC PATCH validates this).
//
// `require_ping` stays as the master switch — when false, the auto-offline
// sweep is skipped entirely and mitras stay online forever once they toggle.
export const getMitraHeartbeatCadenceSeconds = () => {
const raw = process.env.MITRA_HEARTBEAT_CADENCE_SECONDS
if (!raw || raw.trim() === '') return 30
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30
}
// Server timezone. Defaults to UTC and should essentially never be changed —
// see backend/CLAUDE.md "Timezone" note. timestamptz storage and all of our
// instant-based timer math are timezone-INDEPENDENT, so this is belt-and-
// suspenders: it pins the DB session + Node process to one zone so that any
// FUTURE session-tz-dependent SQL (date_trunc / ::date / CURRENT_DATE) and any
// stray local-time Date formatting stay deterministic across deploys.
// NOTE: db/client.js reads `process.env.SERVER_TZ || 'UTC'` directly (it cannot
// import this module without a cycle); keep the default in sync.
export const getServerTimezone = () => {
const raw = process.env.SERVER_TZ
if (!raw || raw.trim() === '') return 'UTC'
return raw.trim()
}
// --- Valkey availability mirror — env-driven cadences ---
//
// Per requirement/valkey-online-mirror-plan.md. All three are operational
// knobs (env, per backend/CLAUDE.md Config-Source Convention), not
// operator-tunable. Defaults match the plan; values are floor-clamped.
export const getMitraAutoOfflineSweepSeconds = () => {
const raw = process.env.MITRA_AUTO_OFFLINE_SWEEP_SECONDS
if (!raw || raw.trim() === '') return 30
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30
}
export const getHeartbeatMirrorIntervalSeconds = () => {
const raw = process.env.HEARTBEAT_MIRROR_INTERVAL_SECONDS
if (!raw || raw.trim() === '') return 60
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed >= 10 ? parsed : 60
}
export const getValkeyOnlineMirrorSweepSeconds = () => {
const raw = process.env.VALKEY_ONLINE_MIRROR_SWEEP_SECONDS
if (!raw || raw.trim() === '') return 300
const parsed = Number.parseInt(raw, 10)
if (parsed === 0) return 0 // explicit disable
return Number.isFinite(parsed) && parsed >= 30 ? parsed : 300
}
// --- Fazpass (OTP provider) ---
//
// Env-driven per backend/CLAUDE.md Config-Source Convention. Read at call
// time (not module load) so test setups can inject via vi.stubEnv. When
// `enabled` is true, otp.service.js routes both /request and /verify through
// Fazpass; when false, the in-process stub plays the role of provider.
export const getFazpassConfig = () => {
const rawTimeout = Number.parseInt(process.env.FAZPASS_TIMEOUT_MS ?? '', 10)
// Trim — dotenv preserves leading whitespace after `=` and a stray space
// would corrupt the `Authorization: Bearer …` header silently.
return {
enabled: process.env.FAZPASS_ENABLED === 'true',
baseUrl: (process.env.FAZPASS_BASE_URL || 'https://api.fazpass.com').trim(),
merchantKey: (process.env.FAZPASS_MERCHANT_KEY ?? '').trim(),
gatewayKey: (process.env.FAZPASS_GATEWAY_KEY ?? '').trim(),
timeoutMs: Number.isFinite(rawTimeout) && rawTimeout >= 1000 ? rawTimeout : 10_000,
}
}
// --- Phase 5: Xendit integration ---
//
// Env-driven (per backend/CLAUDE.md Config-Source Convention). All five values
// read from process.env at call time so test setups can inject via vi.stubEnv.
// When `enabled` is true, payment.service.js mints a real Xendit invoice on
// requestPayment(); when false, invoice creation is skipped and the dev/Maestro
// stub /internal/_test/force-confirm-payment plays the role of the webhook.
// See requirement/phase5-xendit-plan.md D6/D9.
export const getXenditConfig = () => ({
enabled: process.env.XENDIT_ENABLED === 'true',
secretKey: process.env.XENDIT_SECRET_KEY ?? '',
webhookToken: process.env.XENDIT_WEBHOOK_TOKEN ?? '',
successRedirectUrl: process.env.XENDIT_SUCCESS_REDIRECT_URL ?? '',
failureRedirectUrl: process.env.XENDIT_FAILURE_REDIRECT_URL ?? '',
})
export const getMitraPingConfig = async () => {
const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'`
const [staleRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_stale_after_seconds'`
return {
require_ping: requireRow?.value?.value ?? true,
stale_after_seconds: staleRow?.value?.value ?? 45,
heartbeat_cadence_seconds: getMitraHeartbeatCadenceSeconds(),
}
}
export const setMitraPingConfig = async ({ require_ping, stale_after_seconds }) => {
if (require_ping !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('require_mitra_ping', ${sql.json({ value: require_ping })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (stale_after_seconds !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('mitra_stale_after_seconds', ${sql.json({ value: stale_after_seconds })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getMitraPingConfig()
}
export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => {
if (mitra_enabled !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('early_end_mitra_enabled', ${sql.json({ value: mitra_enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (customer_enabled !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('early_end_customer_enabled', ${sql.json({ value: customer_enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getEarlyEndConfig()
}
// --- Phase 3.3: Session Topic Sensitivity ---
export const getSensitivityConfig = async () => {
const [confirmRow] = await sql`SELECT value FROM app_config WHERE key = 'sensitive_flip_confirmation_enabled'`
const [latchRow] = await sql`SELECT value FROM app_config WHERE key = 'sensitive_flag_one_way_latch'`
return {
flip_confirmation_enabled: confirmRow?.value?.value ?? true,
one_way_latch: latchRow?.value?.value ?? false,
}
}
export const setSensitivityConfig = async ({ flip_confirmation_enabled, one_way_latch }) => {
if (flip_confirmation_enabled !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('sensitive_flip_confirmation_enabled', ${sql.json({ value: flip_confirmation_enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (one_way_latch !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('sensitive_flag_one_way_latch', ${sql.json({ value: one_way_latch })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getSensitivityConfig()
}
// --- Phase 3.4: Self-Managed Auth ---
export const getOtpRateLimits = async () => {
const [phoneRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_max_per_phone_per_hour'`
const [ipRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_max_per_ip_per_hour'`
const [resendRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_resend_cooldown_seconds'`
const [attemptsRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_verify_max_attempts'`
return {
max_per_phone_per_hour: phoneRow?.value?.value ?? 3,
max_per_ip_per_hour: ipRow?.value?.value ?? 10,
resend_cooldown_seconds: resendRow?.value?.value ?? 60,
verify_max_attempts: attemptsRow?.value?.value ?? 5,
}
}
export const setOtpRateLimits = async ({
max_per_phone_per_hour,
max_per_ip_per_hour,
resend_cooldown_seconds,
verify_max_attempts,
}) => {
const pairs = [
['otp_max_per_phone_per_hour', max_per_phone_per_hour],
['otp_max_per_ip_per_hour', max_per_ip_per_hour],
['otp_resend_cooldown_seconds', resend_cooldown_seconds],
['otp_verify_max_attempts', verify_max_attempts],
]
for (const [key, value] of pairs) {
if (value === undefined) continue
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES (${key}, ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getOtpRateLimits()
}
export const getCcLoginLockoutConfig = async () => {
const [attemptsRow] = await sql`SELECT value FROM app_config WHERE key = 'cc_login_max_attempts'`
const [minutesRow] = await sql`SELECT value FROM app_config WHERE key = 'cc_login_lockout_minutes'`
return {
max_attempts: attemptsRow?.value?.value ?? 5,
lockout_minutes: minutesRow?.value?.value ?? 15,
}
}
export const setCcLoginLockoutConfig = async ({ max_attempts, lockout_minutes }) => {
if (max_attempts !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('cc_login_max_attempts', ${sql.json({ value: max_attempts })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (lockout_minutes !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('cc_login_lockout_minutes', ${sql.json({ value: lockout_minutes })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getCcLoginLockoutConfig()
}
// --- Paid Pairing Flow + Returning-Chat + Extension Flip ---
export const getPaymentRequestTimeoutMinutes = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_request_timeout_minutes'`
return { payment_request_timeout_minutes: row?.value?.value ?? 20 }
}
export const setPaymentRequestTimeoutMinutes = async (value) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('payment_request_timeout_minutes', ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { payment_request_timeout_minutes: value }
}
export const getReturningChatConfirmationTimeoutSeconds = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'returning_chat_confirmation_timeout_seconds'`
return { returning_chat_confirmation_timeout_seconds: row?.value?.value ?? 20 }
}
export const setReturningChatConfirmationTimeoutSeconds = async (value) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('returning_chat_confirmation_timeout_seconds', ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { returning_chat_confirmation_timeout_seconds: value }
}
export const getExtensionDefaultActionOnTimeout = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_default_action_on_timeout'`
return { extension_default_action_on_timeout: row?.value?.value ?? ExtensionTimeoutAction.AUTO_APPROVE }
}
export const setExtensionDefaultActionOnTimeout = async (value) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('extension_default_action_on_timeout', ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { extension_default_action_on_timeout: value }
}
export const getPairingBlastTimeoutSeconds = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pairing_blast_timeout_seconds'`
return { pairing_blast_timeout_seconds: row?.value?.value ?? 60 }
}
export const setPairingBlastTimeoutSeconds = async (value) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('pairing_blast_timeout_seconds', ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { pairing_blast_timeout_seconds: value }
}
// --- Test OTP bypass allowlist ---
//
// Phone-scoped static-OTP allowlist for Apple App Store reviewers and similar
// pre-launch QA. When the phone in requestOtp() matches an entry here, the
// backend skips Fazpass entirely and plants the entry's pre-hashed OTP into
// otp_requests so the existing verify path works unchanged.
//
// Storage shape:
// {
// enabled: boolean, // global kill switch
// entries: [
// {
// id: uuid,
// phone: "+E.164",
// user_type: "client" | "mitra",
// otp_hash: "$2b$10$...", // bcrypt; plaintext NEVER stored
// label: "Apple Reviewer #1",
// expires_at: "ISO-8601", // per-entry auto-disable
// created_at: "ISO-8601",
// },
// ...
// ],
// }
//
// Plaintext OTP is accepted by setTestOtpBypass at write time, bcrypt-hashed
// before persisting, and is never readable again — list/get returns hashes
// only, callers re-create entries to rotate the secret.
const TEST_OTP_BYPASS_KEY = 'test_otp_bypass'
const PHONE_E164_RE = /^\+[1-9]\d{6,14}$/
const STATIC_OTP_RE = /^\d{4,8}$/
const isValidIsoDate = (v) => {
if (typeof v !== 'string') return false
const d = new Date(v)
return !Number.isNaN(d.getTime())
}
const sanitizeEntry = (entry) => ({
id: entry.id,
phone: entry.phone,
user_type: entry.user_type,
label: entry.label,
// otp_hash is intentionally returned so the CC can show "hash on file" but
// never the plaintext. We could redact further if the CC ever leaks logs.
otp_hash: entry.otp_hash,
expires_at: entry.expires_at,
created_at: entry.created_at,
})
const loadRawBypass = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = ${TEST_OTP_BYPASS_KEY}`
const value = row?.value ?? { enabled: false, entries: [] }
return {
enabled: value.enabled === true,
entries: Array.isArray(value.entries) ? value.entries : [],
}
}
const persistBypass = async ({ enabled, entries }) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES (${TEST_OTP_BYPASS_KEY}, ${sql.json({ enabled, entries })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
export const getTestOtpBypass = async () => {
const raw = await loadRawBypass()
return {
enabled: raw.enabled,
entries: raw.entries.map(sanitizeEntry),
}
}
/**
* Hot-path matcher used by requestOtp(). Returns the matching entry (with
* otp_hash) if (kill switch on) + (phone exact match) + (not expired) +
* (user_type matches). Returns null otherwise.
*
* Every call is a fresh DB SELECT — same pattern as the other config getters.
* Cache TBD (see project memory: `config_cache_pending`).
*/
export const getTestOtpBypassMatch = async ({ phone, userType }) => {
const raw = await loadRawBypass()
if (!raw.enabled) return null
const now = Date.now()
for (const entry of raw.entries) {
if (entry.phone !== phone) continue
if (entry.user_type !== userType) continue
if (!entry.expires_at) continue
const exp = new Date(entry.expires_at).getTime()
if (!Number.isFinite(exp) || exp <= now) continue
return entry
}
return null
}
export const setTestOtpBypassEnabled = async (enabled) => {
if (typeof enabled !== 'boolean') {
throw Object.assign(new Error('enabled must be a boolean'), {
code: 'VALIDATION_ERROR', statusCode: 422,
})
}
const raw = await loadRawBypass()
await persistBypass({ ...raw, enabled })
return getTestOtpBypass()
}
/**
* Add an entry. `otp` is plaintext (4-8 digits); we hash before persisting
* and do not return it after. Phone must be E.164. user_type must match the
* UserType enum (client | mitra). expires_at is required and must be in the
* future. Duplicate (phone, user_type) is rejected.
*/
export const addTestOtpBypassEntry = async ({ phone, otp, user_type, label, expires_at }) => {
if (typeof phone !== 'string' || !PHONE_E164_RE.test(phone)) {
throw Object.assign(new Error('phone must be E.164 (e.g. +628...)'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'phone',
})
}
if (typeof otp !== 'string' || !STATIC_OTP_RE.test(otp)) {
throw Object.assign(new Error('otp must be a 4-8 digit numeric string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'otp',
})
}
if (user_type !== UserType.CUSTOMER && user_type !== UserType.MITRA) {
throw Object.assign(new Error(`user_type must be "${UserType.CUSTOMER}" or "${UserType.MITRA}"`), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'user_type',
})
}
if (typeof label !== 'string' || label.trim().length === 0) {
throw Object.assign(new Error('label is required'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'label',
})
}
if (!isValidIsoDate(expires_at)) {
throw Object.assign(new Error('expires_at must be an ISO-8601 datetime string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
})
}
if (new Date(expires_at).getTime() <= Date.now()) {
throw Object.assign(new Error('expires_at must be in the future'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
})
}
const raw = await loadRawBypass()
if (raw.entries.some((e) => e.phone === phone && e.user_type === user_type)) {
throw Object.assign(new Error('An entry for this phone + user_type already exists'), {
code: 'DUPLICATE_ENTRY', statusCode: 422, field: 'phone',
})
}
const otpHash = await bcrypt.hash(otp, TEST_OTP_BYPASS_BCRYPT_COST)
const entry = {
id: crypto.randomUUID(),
phone,
user_type,
label: label.trim(),
otp_hash: otpHash,
expires_at: new Date(expires_at).toISOString(),
created_at: new Date().toISOString(),
}
raw.entries.push(entry)
await persistBypass(raw)
return sanitizeEntry(entry)
}
/**
* Patch an entry by id. Supported fields: label, expires_at, otp (plaintext
* → rehashed). Phone and user_type are immutable — delete + re-add to change
* them, so the audit trail stays clean.
*/
export const updateTestOtpBypassEntry = async (id, patch) => {
if (typeof id !== 'string' || id.length === 0) {
throw Object.assign(new Error('id is required'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'id',
})
}
const raw = await loadRawBypass()
const idx = raw.entries.findIndex((e) => e.id === id)
if (idx < 0) {
throw Object.assign(new Error('Entry not found'), {
code: 'NOT_FOUND', statusCode: 404,
})
}
const current = raw.entries[idx]
const next = { ...current }
if (patch.label !== undefined) {
if (typeof patch.label !== 'string' || patch.label.trim().length === 0) {
throw Object.assign(new Error('label must be a non-empty string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'label',
})
}
next.label = patch.label.trim()
}
if (patch.expires_at !== undefined) {
if (!isValidIsoDate(patch.expires_at)) {
throw Object.assign(new Error('expires_at must be an ISO-8601 datetime string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
})
}
if (new Date(patch.expires_at).getTime() <= Date.now()) {
throw Object.assign(new Error('expires_at must be in the future'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
})
}
next.expires_at = new Date(patch.expires_at).toISOString()
}
if (patch.otp !== undefined) {
if (typeof patch.otp !== 'string' || !STATIC_OTP_RE.test(patch.otp)) {
throw Object.assign(new Error('otp must be a 4-8 digit numeric string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'otp',
})
}
next.otp_hash = await bcrypt.hash(patch.otp, TEST_OTP_BYPASS_BCRYPT_COST)
}
raw.entries[idx] = next
await persistBypass(raw)
return sanitizeEntry(next)
}
export const deleteTestOtpBypassEntry = async (id) => {
const raw = await loadRawBypass()
const before = raw.entries.length
raw.entries = raw.entries.filter((e) => e.id !== id)
if (raw.entries.length === before) {
throw Object.assign(new Error('Entry not found'), {
code: 'NOT_FOUND', statusCode: 404,
})
}
await persistBypass(raw)
return { deleted: true, id }
}