Age parser (src/routes/billing.js):
- Now sums year + month + week + day matches so "4 yr 11 mo"
(59 months) correctly maps to the 5-11y billing bracket instead
of being billed as 1-4y. Added bounds sanity check.
Graceful SIGTERM shutdown (server.js):
- Closes the HTTP listener first, then drains batched audit queues,
then ends the Postgres pool. 9-second hard deadline to beat
Docker's 10-second SIGKILL. Previously an in-flight note save
during a container restart could truncate the write.
Explicit LLM fallback opt-in (src/utils/ai.js):
- The OpenRouter / LiteLLM silent fallback now requires admin
setting `ai.allow_model_fallback = true` (default: false). If
primary fails and fallback is disabled, the error is surfaced
to the caller. Prevents silent spillover from a BAA-covered
primary to a non-covered fallback.
Prompt injection delimiters (src/utils/promptSafe.js):
- Wraps user transcripts, dictations, refine-instructions, and
pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
appends an explicit system instruction telling the model to
treat the wrapped content as data rather than commands.
- Applied to soap.js, hpi.js, refine.js. Extend to other AI
routes incrementally.
Cross-tab logout sync (public/js/authFetch.js, auth.js):
- BroadcastChannel('pedscribe-auth') — logout in one tab posts
a message; all sibling tabs clear state and reload, dropping
any PHI-containing UI immediately.
Backup code race-free consumption (src/routes/auth.js):
- tryConsumeBackupCode() now uses a Postgres transaction with
SELECT ... FOR UPDATE so concurrent login attempts using the
same code serialize. First wins, second sees the already-
shortened array.
Optimistic encounter locking (migrations/...add-encounter-version):
- saved_encounters.version INTEGER NOT NULL DEFAULT 1
- POST /api/encounters/saved accepts an expected_version and
rejects with 409 if the row has advanced. Falls back to
last-write-wins if the client doesn't pass one (backward compat).
Audit log batching (src/utils/auditQueue.js):
- Audit / api_log / access_log writes are buffered in memory and
flushed every 1s or every 50 entries via one multi-row INSERT.
Under load this reduces DB pressure by ~50x. On SIGTERM the
shutdown path drains the queue before exiting.
113 lines
3.7 KiB
JavaScript
113 lines
3.7 KiB
JavaScript
// ============================================================
|
|
// Audit log batcher — collects audit/api/access entries in memory
|
|
// and flushes them to Postgres as batched multi-row INSERTs on a
|
|
// 1-second interval or when a buffer reaches 50 entries. Reduces
|
|
// per-request DB load from "one INSERT per audit call" to "one
|
|
// INSERT per ~50 audit calls under load". Transparent to callers.
|
|
//
|
|
// Trade-off: if the process crashes between flushes, up to ~1s of
|
|
// audit entries are lost. The PostgreSQL row is the primary
|
|
// destination; Loki is separately pushed fire-and-forget per call
|
|
// and is not batched (Loki has its own server-side ingestion that
|
|
// handles batching more cheaply).
|
|
// ============================================================
|
|
|
|
var FLUSH_INTERVAL_MS = 1000;
|
|
var FLUSH_MAX_BATCH = 50;
|
|
|
|
function createQueue(tableName, columns) {
|
|
var buffer = [];
|
|
var flushing = false;
|
|
var timer = null;
|
|
|
|
// Build "($1,$2,$3,...),($N,$N+1,...),..." with running placeholder index
|
|
function buildBatchSql(rows) {
|
|
var cols = columns.join(', ');
|
|
var placeholders = [];
|
|
var params = [];
|
|
var k = 1;
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var row = rows[i];
|
|
var tuple = [];
|
|
for (var j = 0; j < columns.length; j++) {
|
|
tuple.push('$' + k++);
|
|
params.push(row[columns[j]] != null ? row[columns[j]] : null);
|
|
}
|
|
placeholders.push('(' + tuple.join(',') + ')');
|
|
}
|
|
return {
|
|
sql: 'INSERT INTO ' + tableName + ' (' + cols + ') VALUES ' + placeholders.join(','),
|
|
params: params
|
|
};
|
|
}
|
|
|
|
async function flush() {
|
|
if (flushing || buffer.length === 0) return;
|
|
flushing = true;
|
|
var toFlush = buffer.splice(0, FLUSH_MAX_BATCH);
|
|
try {
|
|
var pool = require('../db/database').pool;
|
|
var q = buildBatchSql(toFlush);
|
|
await pool.query(q.sql, q.params);
|
|
} catch (err) {
|
|
console.error('[auditQueue:' + tableName + '] flush failed:', err && err.message);
|
|
// Don't re-buffer — these are audit entries, not user data.
|
|
// Losing a burst on DB hiccup is acceptable; the alternative is
|
|
// unbounded memory growth during DB outages.
|
|
} finally {
|
|
flushing = false;
|
|
// If more accumulated during the await, schedule another round
|
|
if (buffer.length >= FLUSH_MAX_BATCH) setImmediate(flush);
|
|
}
|
|
}
|
|
|
|
function push(row) {
|
|
buffer.push(row);
|
|
if (buffer.length >= FLUSH_MAX_BATCH) setImmediate(flush);
|
|
if (!timer) {
|
|
timer = setInterval(flush, FLUSH_INTERVAL_MS);
|
|
if (typeof timer.unref === 'function') timer.unref();
|
|
}
|
|
}
|
|
|
|
// Called from the SIGTERM shutdown path — drains any remaining
|
|
// entries before the process exits.
|
|
async function drain() {
|
|
if (timer) { clearInterval(timer); timer = null; }
|
|
while (buffer.length > 0) {
|
|
await flush();
|
|
if (flushing) { await new Promise(function(r) { setTimeout(r, 20); }); }
|
|
}
|
|
}
|
|
|
|
return { push: push, drain: drain, size: function() { return buffer.length; } };
|
|
}
|
|
|
|
var auditQueue = createQueue('audit_log', [
|
|
'user_id', 'action', 'category', 'details',
|
|
'ip_address', 'user_agent', 'model_used', 'tokens_used', 'duration_ms', 'status'
|
|
]);
|
|
|
|
var apiQueue = createQueue('api_log', [
|
|
'user_id', 'endpoint', 'method', 'status_code',
|
|
'request_size', 'response_size', 'model_used',
|
|
'tokens_input', 'tokens_output', 'cost_estimate',
|
|
'duration_ms', 'ip_address', 'error'
|
|
]);
|
|
|
|
var accessQueue = createQueue('access_log', [
|
|
'user_id', 'action', 'ip_address', 'user_agent', 'success'
|
|
]);
|
|
|
|
async function drainAll() {
|
|
try { await auditQueue.drain(); } catch(e) {}
|
|
try { await apiQueue.drain(); } catch(e) {}
|
|
try { await accessQueue.drain(); } catch(e) {}
|
|
}
|
|
|
|
module.exports = {
|
|
audit: auditQueue,
|
|
api: apiQueue,
|
|
access: accessQueue,
|
|
drainAll: drainAll
|
|
};
|