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 } // --- 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 } }