Skip to content

Commit 4386711

Browse files
committed
fix: address all 12 Copilot review issues on PR #15
Security: - Prevent cross-brain memory reassignment via import (add WHERE brain_id guard) - Prevent cross-brain link reassignment via import (add WHERE brain_id guard) - Validate overwrite import has memories before deleting existing data Data integrity: - Include title/key/source in vector sync for imported memories - Export link timestamps (created_at) for round-trip fidelity - Use result.meta.changes for accurate changelog import stats - Delete brain_policies on purge for full reset HTTP: - Add Cache-Control: no-store on export response - Remove unused parseJsonObject and loadExplicitMemoryLinks imports Viewer: - Re-render graph on theme/mode change when graph is visible - Reset file input value in resetImportSteps to allow re-selecting same file - Use case-insensitive .json extension check for import file validation https://claude.ai/code/session_01FkqEYSoHZTsWpdJSgo4ioL
1 parent 24da283 commit 4386711

2 files changed

Lines changed: 35 additions & 13 deletions

File tree

src/routes.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,7 @@ import {
3636
loadSourceTrustMap,
3737
getBrainPolicy,
3838
setBrainPolicy,
39-
loadExplicitMemoryLinks,
4039
logChangelog,
41-
parseJsonObject,
4240
} from './db.js';
4341

4442
import {
@@ -450,7 +448,11 @@ export async function handleApiExport(env: Env, brainId: string): Promise<Respon
450448
FROM memories WHERE brain_id = ? ORDER BY created_at DESC LIMIT 50000`
451449
).bind(brainId).all<Record<string, unknown>>();
452450

453-
const links = await loadExplicitMemoryLinks(env, brainId, 50000);
451+
const linksRows = await env.DB.prepare(
452+
`SELECT id, from_id, to_id, relation_type, label, created_at
453+
FROM memory_links WHERE brain_id = ? ORDER BY created_at DESC LIMIT 50000`
454+
).bind(brainId).all<Record<string, unknown>>();
455+
const links = linksRows.results;
454456

455457
const changelog = await env.DB.prepare(
456458
`SELECT id, event_type, entity_type, entity_id, summary, payload, created_at
@@ -514,6 +516,8 @@ export async function handleApiExport(env: Env, brainId: string): Promise<Respon
514516
...CORS_HEADERS,
515517
'Content-Type': 'application/json',
516518
'Content-Disposition': `attachment; filename="${filename}"`,
519+
'Cache-Control': 'no-store',
520+
'Pragma': 'no-cache',
517521
},
518522
});
519523
}
@@ -569,6 +573,13 @@ export async function handleApiImport(request: Request, env: Env, brainId: strin
569573
const aliasesPayload = Array.isArray(data.memory_entity_aliases) ? data.memory_entity_aliases as Array<Record<string, unknown>> : [];
570574
const watchesPayload = Array.isArray(data.memory_watches) ? data.memory_watches as Array<Record<string, unknown>> : [];
571575

576+
// Validate payload has restorable content before destructive overwrite
577+
if (strategy === 'overwrite' && memoriesPayload.length === 0) {
578+
return new Response(JSON.stringify({ error: 'Overwrite import requires at least one memory in the payload. Aborting to prevent data loss.' }), {
579+
status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
580+
});
581+
}
582+
572583
const ts = now();
573584
const counts = { memories: 0, memory_links: 0, memory_changelog: 0, brain_source_trust: 0, memory_conflict_resolutions: 0, memory_entity_aliases: 0, memory_watches: 0, skipped: 0 };
574585
const restoredMemoryRows: Array<Record<string, unknown>> = [];
@@ -608,10 +619,11 @@ export async function handleApiImport(request: Request, env: Env, brainId: strin
608619
`INSERT INTO memories (id, brain_id, type, title, key, content, tags, source, confidence, importance, archived_at, created_at, updated_at)
609620
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
610621
ON CONFLICT(id) DO UPDATE SET
611-
brain_id = excluded.brain_id, type = excluded.type, title = excluded.title, key = excluded.key,
622+
type = excluded.type, title = excluded.title, key = excluded.key,
612623
content = excluded.content, tags = excluded.tags, source = excluded.source,
613624
confidence = excluded.confidence, importance = excluded.importance,
614-
archived_at = excluded.archived_at, created_at = excluded.created_at, updated_at = excluded.updated_at`
625+
archived_at = excluded.archived_at, created_at = excluded.created_at, updated_at = excluded.updated_at
626+
WHERE memories.brain_id = excluded.brain_id`
615627
).bind(
616628
memoryId, brainId, type,
617629
typeof m.title === 'string' ? m.title : null,
@@ -624,7 +636,13 @@ export async function handleApiImport(request: Request, env: Env, brainId: strin
624636
archivedAt, createdAt, updatedAt
625637
).run();
626638

627-
restoredMemoryRows.push({ id: memoryId, type, content, tags: typeof m.tags === 'string' ? m.tags : null });
639+
restoredMemoryRows.push({
640+
id: memoryId, type, content,
641+
title: typeof m.title === 'string' ? m.title : null,
642+
key: typeof m.key === 'string' ? m.key : null,
643+
tags: typeof m.tags === 'string' ? m.tags : null,
644+
source: typeof m.source === 'string' ? m.source : null,
645+
});
628646
counts.memories++;
629647
}
630648

@@ -653,8 +671,9 @@ export async function handleApiImport(request: Request, env: Env, brainId: strin
653671
`INSERT INTO memory_links (id, brain_id, from_id, to_id, relation_type, label, created_at)
654672
VALUES (?, ?, ?, ?, ?, ?, ?)
655673
ON CONFLICT(id) DO UPDATE SET
656-
brain_id = excluded.brain_id, from_id = excluded.from_id, to_id = excluded.to_id,
657-
relation_type = excluded.relation_type, label = excluded.label`
674+
from_id = excluded.from_id, to_id = excluded.to_id,
675+
relation_type = excluded.relation_type, label = excluded.label
676+
WHERE memory_links.brain_id = excluded.brain_id`
658677
).bind(
659678
linkId, brainId, fromId, toId,
660679
normalizeRelation(link.relation_type),
@@ -673,15 +692,15 @@ export async function handleApiImport(request: Request, env: Env, brainId: strin
673692
const entityId = typeof entry.entity_id === 'string' ? entry.entity_id : '';
674693
const summary = typeof entry.summary === 'string' ? entry.summary : '';
675694
if (!eventType || !entityType || !entityId || !summary) continue;
676-
await env.DB.prepare(
695+
const result = await env.DB.prepare(
677696
`INSERT OR IGNORE INTO memory_changelog (id, brain_id, event_type, entity_type, entity_id, summary, payload, created_at)
678697
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
679698
).bind(
680699
entryId, brainId, eventType, entityType, entityId, summary,
681700
typeof entry.payload === 'string' ? entry.payload : (entry.payload ? stableJson(entry.payload) : null),
682701
Math.floor(toFiniteNumber(entry.created_at, ts))
683702
).run();
684-
counts.memory_changelog++;
703+
if (result.meta.changes > 0) counts.memory_changelog++;
685704
}
686705

687706
for (const raw of sourceTrustPayload) {
@@ -810,6 +829,7 @@ export async function handleApiPurge(request: Request, env: Env, brainId: string
810829
await env.DB.prepare('DELETE FROM memory_watches WHERE brain_id = ?').bind(brainId).run();
811830
await env.DB.prepare('DELETE FROM brain_source_trust WHERE brain_id = ?').bind(brainId).run();
812831
await env.DB.prepare('DELETE FROM brain_snapshots WHERE brain_id = ?').bind(brainId).run();
832+
await env.DB.prepare('DELETE FROM brain_policies WHERE brain_id = ?').bind(brainId).run();
813833
await env.DB.prepare('DELETE FROM memories WHERE brain_id = ?').bind(brainId).run();
814834

815835
return new Response(JSON.stringify({ ok: true, purged: { memories: memoryCount, links: linkCount } }), {

src/viewer.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3031,6 +3031,8 @@ export function viewerScript(): string {
30313031
const statusEl = document.getElementById('import-status-line');
30323032
const metaEl = document.getElementById('import-status-meta');
30333033
importSelectedFile = null;
3034+
const fileInput = document.getElementById('import-file-input');
3035+
if (fileInput) fileInput.value = '';
30343036
if (nameEl) nameEl.textContent = '';
30353037
if (stepStrategy) stepStrategy.style.display = 'none';
30363038
if (stepRun) stepRun.style.display = 'none';
@@ -3057,7 +3059,7 @@ export function viewerScript(): string {
30573059
resetImportSteps();
30583060
return;
30593061
}
3060-
if (!file.name.endsWith('.json')) {
3062+
if (!file.name.toLowerCase().endsWith('.json')) {
30613063
showToast('Please select a .json file.', 'error');
30623064
resetImportSteps();
30633065
return;
@@ -4707,7 +4709,7 @@ export function viewerScript(): string {
47074709
viewerSettings.theme = themeValue;
47084710
}
47094711
persistViewerSettings();
4710-
applyViewerSettingsToRuntime({ restartPolling: false, rerenderGraph: false, rerenderGrid: false });
4712+
applyViewerSettingsToRuntime({ restartPolling: false, rerenderGraph: graphVisible, rerenderGrid: false });
47114713
return;
47124714
}
47134715
@@ -4718,7 +4720,7 @@ export function viewerScript(): string {
47184720
viewerSettings = readSettingsFromForm();
47194721
viewerSettings.theme_mode = mode;
47204722
persistViewerSettings();
4721-
applyViewerSettingsToRuntime({ restartPolling: false, rerenderGraph: false, rerenderGrid: false });
4723+
applyViewerSettingsToRuntime({ restartPolling: false, rerenderGraph: graphVisible, rerenderGrid: false });
47224724
return;
47234725
}
47244726
});

0 commit comments

Comments
 (0)