chore: fork with local patches for Syslog Solution

Patches applied:
- enforceRelayTTL — auto-deletes expired relay nodes (>7d)
- updateNode — tenant enforcement (block updates on NULL-namespace nodes)
- buildCanonicalMetadata — reject nested objects, sanitize values
- createNode — source-based and title-based deduplication
- getContext — NPE-safe count queries, stale node detection
- repair_log integration on updateNode
- bridge-validation.js — auto-fill defaults for tenant/namespace

Origin: github.com/bradwmorris/ra-h_os@70e95e7
This commit is contained in:
2026-06-01 09:37:19 -04:00
parent 70e95e7d29
commit 88228a6104
5 changed files with 417 additions and 86 deletions
@@ -131,22 +131,50 @@ function buildCanonicalMetadata({ existing, metadata }) {
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
};
const merged = {
...prior,
...incoming,
state: incoming.state === 'processed' ? 'processed' : (prior.state === 'processed' ? 'processed' : 'not_processed'),
captured_by: incoming.captured_by || prior.captured_by || 'human',
source_metadata: sourceMetadata,
};
// Sanitize a value: reject nested objects/arrays, return trimmed string or undefined
function sanitizeValue(value) {
if (value === undefined || value === null) return undefined;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (typeof value === 'object') {
// Unwrap nested {value: ...} format from MCP tool calls
if (value.value && typeof value.value === 'string') return value.value.trim();
console.warn(`[buildCanonicalMetadata] WARNING: Nested object/array rejected. Value: ${JSON.stringify(value).substring(0, 200)}`);
return undefined;
}
return undefined;
}
const type = normalizeString(incoming.type) || normalizeString(prior.type);
const capturedMethod = normalizeString(incoming.captured_method) || normalizeString(prior.captured_method);
// Build merged metadata from prior and incoming, but only keep string values
const merged = {};
if (type) merged.type = type;
else delete merged.type;
// Start with source_metadata (always an object)
merged.source_metadata = sourceMetadata;
if (capturedMethod) merged.captured_method = capturedMethod;
else delete merged.captured_method;
// Copy from prior: only string values
Object.keys(prior).forEach(key => {
if (key === 'source_metadata') return; // handled separately
const val = prior[key];
if (typeof val === 'string' && val.trim().length > 0) {
merged[key] = val;
}
});
// Override with incoming: only string values
Object.keys(incoming).forEach(key => {
if (key === 'source_metadata') return; // handled separately
const sanitized = sanitizeValue(incoming[key]);
const val = sanitized;
if (typeof val === 'string' && val.trim().length > 0) {
merged[key] = val.trim();
}
});
// Ensure canonical defaults
if (!merged.state) merged.state = 'not_processed';
if (!merged.captured_by) merged.captured_by = 'human';
return merged;
}
@@ -402,6 +430,36 @@ function getChunkStatusForSource(sourceText) {
/**
* Create a new node.
*/
/**
* Enforce TTL on relay messages — delete expired ones.
* Relay messages have a TTL of 7 days.
*/
function enforceRelayTTL() {
const db = getDb();
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 7);
const cutoffISO = cutoff.toISOString();
// Find relay messages older than 7 days
const expired = query(`
SELECT id, title, created_at FROM nodes
WHERE metadata LIKE '%relay_message%'
AND created_at < ?
ORDER BY created_at ASC
`, [cutoffISO]);
if (expired.length > 0) {
const ids = expired.map(n => n.id);
const idsStr = ids.join(',');
console.warn(`[enforceRelayTTL] ${expired.length} relay messages expired, deleting (IDs: ${idsStr})`);
db.prepare(`DELETE FROM nodes WHERE id IN (${idsStr})`).run();
db.prepare(`DELETE FROM repair_log WHERE node_id IN (${idsStr})`).run();
db.prepare(`DELETE FROM edges WHERE from_node_id IN (${idsStr}) OR to_node_id IN (${idsStr})`).run();
}
return expired.length;
}
function createNode(nodeData) {
const {
title: rawTitle,
@@ -414,6 +472,34 @@ function createNode(nodeData) {
const title = sanitizeTitle(rawTitle);
// FIX 2: Deduplication — check if a node with same source or title+description already exists
const existingSource = source || ([title, description].filter(Boolean).join('\n\n').trim() || null);
const existingSourceNormalized = normalizeString(existingSource);
const existingTitleNormalized = normalizeString(title);
// Check for source-based deduplication first
const sourceCheckResults = query(`
SELECT id, title FROM nodes WHERE source = ?
`, [existingSourceNormalized || '']);
if (sourceCheckResults.length > 0) {
console.warn(`[createNode] WARNING: Node with identical source already exists (ID: ${sourceCheckResults[0].id}, Title: ${sourceCheckResults[0].title}). Skipping duplicate creation.`);
return getNodeById(Number(sourceCheckResults[0].id));
}
// Check for title+description deduplication
const dedupCheckResults = query(`
SELECT id, title FROM nodes WHERE title = ?
`, [title]);
if (dedupCheckResults.length > 0) {
console.warn(`[createNode] WARNING: Node with identical title already exists (ID: ${dedupCheckResults[0].id}, Title: ${dedupCheckResults[0].title}). Skipping duplicate creation.`);
return getNodeById(Number(dedupCheckResults[0].id));
}
// Enforce TTL on relay messages before creating new node
enforceRelayTTL();
const canonicalMetadata = buildCanonicalMetadata({ metadata });
const now = new Date().toISOString();
const db = getDb();
@@ -449,6 +535,9 @@ function createNode(nodeData) {
* Update an existing node.
*/
function updateNode(id, updates, options = {}) {
// Enforce TTL on relay messages before updating any node
enforceRelayTTL();
const { title, description, source, link, event_date, metadata } = updates;
const now = new Date().toISOString();
const db = getDb();
@@ -459,7 +548,32 @@ function updateNode(id, updates, options = {}) {
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
}
const mergedMetadata = metadata !== undefined
// === TENANT ENFORCEMENT: require namespace/tenant in metadata ===
// Extract tenant from existing node metadata
let nodeTenant = null;
try {
const meta = typeof existing.metadata === 'string'
? JSON.parse(existing.metadata)
: existing.metadata;
nodeTenant = meta.namespace || null;
} catch (e) {
// metadata parse error — treat as no tenant
}
// Block update if no tenant/namespace
// Bootstrap: allow updates that SET namespace on NULL-namespace nodes
const metaUpdate = metadata || {};
const isSettingNamespace = metaUpdate.namespace && (
typeof metaUpdate.namespace === 'string' ||
(typeof metaUpdate.namespace === 'object' && metaUpdate.namespace.value)
);
if (!nodeTenant && !isSettingNamespace) {
console.warn(`[updateNode] BLOCK: Node #${id} has no tenant/namespace. Refusing update.`);
throw new Error(`Node #${id} has no namespace field in metadata. Update blocked. Set metadata.namespace before updating.`);
}
const mergedMetadata = metadata !== undefined
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
: undefined;
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
@@ -510,6 +624,23 @@ function updateNode(id, updates, options = {}) {
});
// === REPAIR LOG: log the update ===
try {
const logStmt = db.prepare(
'INSERT INTO repair_log(node_id, action, old_metadata, new_metadata, timestamp, verified, tenant) VALUES (?, ?, ?, ?, datetime(?), 0, ?)'
);
logStmt.run(
id,
'updateNode',
typeof existing.metadata === 'string' ? existing.metadata : JSON.stringify(existing.metadata ?? null),
mergedMetadata ? JSON.stringify(mergedMetadata) : 'unchanged',
new Date().toISOString().split('T')[0],
nodeTenant || 'unknown'
);
} catch (logErr) {
console.warn(`[updateNode] repair_log write failed: ${logErr.message}`);
}
return getNodeById(id);
}
@@ -537,8 +668,10 @@ function getNodeCount() {
* Returns stats, hub nodes, and recent activity.
*/
function getContext() {
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
const edgeCount = query('SELECT COUNT(*) as count FROM edges')[0].count;
const nodeCountRow = query('SELECT COUNT(*) as count FROM nodes');
const nodeCount = (nodeCountRow && nodeCountRow[0] && nodeCountRow[0].count) || 0;
const edgeCountRow = query('SELECT COUNT(*) as count FROM edges');
const edgeCount = (edgeCountRow && edgeCountRow[0] && edgeCountRow[0].count) || 0;
const recentNodes = query(`
SELECT n.id, n.title, n.description
@@ -556,10 +689,34 @@ function getContext() {
LIMIT 10
`);
// Get all nodes for bulk operations
const allNodes = query(`
SELECT n.id, n.title, n.description, n.metadata, n.created_at, n.updated_at
FROM nodes n
ORDER BY n.updated_at DESC
`).map(row => ({
id: row.id,
title: row.title,
description: row.description ?? null,
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
created_at: row.created_at,
updated_at: row.updated_at
}));
// Detect stale nodes (no update in 30 days, state is not_processed)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const staleNodes = allNodes.filter(n =>
n.metadata?.state === 'not_processed' &&
n.updated_at &&
n.updated_at < thirtyDaysAgo
);
return {
stats: { nodeCount, edgeCount, dimensionCount: 0 },
recentNodes,
hubNodes
hubNodes,
allNodes,
staleNodes
};
}