gigafibre-fsm/services/targo-hub/lib/telephony.js
louispaulb 041df48ac3 security: harden payments/hub auth + remove leaked ERPNext token from source
Auth hardening:
- payments: per-customer JWT authorization on dual-use /payments routes
  (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer
  + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage)
- hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name
  guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion

Leaked-credential cleanup (token already rotated in ERPNext):
- de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py,
  apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects)
- ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty)
- de-hardcode legacy DB password -> env in import_items.py
- gitignore legacy migration PII exports (tsv/json)

Conversations/UI:
- floating (un-docked) conversation panel; full-width mailbox
- per-message real sender from email headers; unified scroll; header spacing
- campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:17:17 -04:00

104 lines
5.0 KiB
JavaScript

'use strict'
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
let routrPool = null, identityPool = null
function getPool (connStr, label) {
const { Pool } = require('pg')
const pool = new Pool({ connectionString: connStr, max: 3 })
pool.on('error', e => log(`${label} DB pool error:`, e.message))
return pool
}
function getRoutrPool () { return routrPool || (routrPool = getPool(cfg.ROUTR_DB_URL, 'Routr')) }
function getIdentityPool () { return identityPool || (identityPool = getPool(cfg.FNIDENTITY_DB_URL, 'Identity')) }
const ROUTR_TABLES = { trunks: 'trunks', agents: 'agents', credentials: 'credentials', numbers: 'numbers', domains: 'domains', acls: 'access_control_lists', peers: 'peers' }
const IDENTITY_TABLES = { workspaces: 'workspaces', users: 'users' }
async function handle (req, res, method, path, url) {
try {
const parts = path.replace('/telephony/', '').split('/').filter(Boolean)
const resource = parts[0], ref = parts[1] || null
if (resource === 'overview' && method === 'GET') {
const rPool = getRoutrPool(), iPool = getIdentityPool()
const [trunks, agents, creds, numbers, domains, peers, workspaces] = await Promise.all([
rPool.query('SELECT COUNT(*) FROM trunks'),
rPool.query('SELECT COUNT(*) FROM agents'),
rPool.query('SELECT COUNT(*) FROM credentials'),
rPool.query('SELECT COUNT(*) FROM numbers'),
rPool.query('SELECT COUNT(*) FROM domains'),
rPool.query('SELECT COUNT(*) FROM peers'),
iPool.query('SELECT COUNT(*) FROM workspaces'),
])
return json(res, 200, {
trunks: +trunks.rows[0].count, agents: +agents.rows[0].count,
credentials: +creds.rows[0].count, numbers: +numbers.rows[0].count,
domains: +domains.rows[0].count, peers: +peers.rows[0].count,
workspaces: +workspaces.rows[0].count,
})
}
const isIdentity = !!IDENTITY_TABLES[resource]
const tableName = IDENTITY_TABLES[resource] || ROUTR_TABLES[resource]
if (!tableName) {
return json(res, 404, { error: `Unknown resource: ${resource}. Available: overview, ${[...Object.keys(ROUTR_TABLES), ...Object.keys(IDENTITY_TABLES)].join(', ')}` })
}
const pool = isIdentity ? getIdentityPool() : getRoutrPool()
if (method === 'GET' && !ref) {
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
const result = await pool.query(`SELECT * FROM ${tableName} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [limit, offset])
const count = await pool.query(`SELECT COUNT(*) FROM ${tableName}`)
return json(res, 200, { items: result.rows, total: +count.rows[0].count })
}
if (method === 'GET' && ref) {
const result = await pool.query(`SELECT * FROM ${tableName} WHERE ref = $1`, [ref])
return result.rows.length ? json(res, 200, result.rows[0]) : json(res, 404, { error: 'Not found' })
}
if (method === 'POST' && !ref) {
const body = await parseBody(req)
if (!body.ref) body.ref = require('crypto').randomUUID()
if (!body.api_version) body.api_version = 'v2'
if (!body.created_at) body.created_at = new Date()
if (!body.updated_at) body.updated_at = new Date()
const keys = Object.keys(body), vals = Object.values(body)
if (keys.some(k => !/^[a-z_][a-z0-9_]*$/i.test(k))) return json(res, 400, { error: 'Invalid field name' }) // anti-SQLi : clés=colonnes, on rejette tout identifiant non standard
const result = await pool.query(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${keys.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING *`, vals)
log(`Telephony: created ${resource}/${result.rows[0].ref}`)
return json(res, 201, result.rows[0])
}
if (method === 'PUT' && ref) {
const body = await parseBody(req)
body.updated_at = new Date(); delete body.ref; delete body.created_at
const keys = Object.keys(body), vals = Object.values(body)
if (keys.some(k => !/^[a-z_][a-z0-9_]*$/i.test(k))) return json(res, 400, { error: 'Invalid field name' }) // anti-SQLi : clés=colonnes
vals.push(ref)
const result = await pool.query(`UPDATE ${tableName} SET ${keys.map((k, i) => `${k} = $${i + 1}`).join(', ')} WHERE ref = $${vals.length} RETURNING *`, vals)
if (!result.rows.length) return json(res, 404, { error: 'Not found' })
log(`Telephony: updated ${resource}/${ref}`)
return json(res, 200, result.rows[0])
}
if (method === 'DELETE' && ref) {
const result = await pool.query(`DELETE FROM ${tableName} WHERE ref = $1 RETURNING ref`, [ref])
if (!result.rows.length) return json(res, 404, { error: 'Not found' })
log(`Telephony: deleted ${resource}/${ref}`)
return json(res, 200, { ok: true, deleted: ref })
}
return json(res, 405, { error: 'Method not allowed' })
} catch (e) {
log('Telephony error:', e.message)
return json(res, 500, { error: e.message })
}
}
module.exports = { handle }