morphit/apps/web/scripts/chat-own-sent-plaintext-cache-smoke.ts

163 lines
6.1 KiB
TypeScript

#!/usr/bin/env tsx
/**
* chat-own-sent-plaintext-cache-smoke (cp402 [3]).
*
* Pins the fix for "my own messages show (encrypted) after navigating
* away and back". The chat crypto (crypto.ts) is ephemeral sender-PFS:
* the ephemeral private key is wiped immediately after encrypting, so we
* can NEVER re-decrypt our own sent messages from chain history. During a
* live session the composer keeps the plaintext as a local optimistic
* echo, but a fresh controller (after navigating away and back) has no
* echo — mergePollResponse's our-own-sent branch fails to reconcile by
* client_tag and would render the placeholder. The fix is an in-memory
* own-sent plaintext cache, keyed by account + client_tag, that a fresh
* controller reads to restore the text.
*
* SAFETY / PRIVACY INVARIANTS this smoke guards (source-assertion, same
* rationale as chat-blocks-race-guard-smoke — the full flow needs the
* live chat runtime + libsodium, which a smoke can't cheaply stand up):
*
* • WRITE — sendMessage remembers our plaintext keyed by (me, tag).
* • READ — the our-own-sent merge branch restores from the cache, and
* the read is GATED on getLiveIdentity(): a LOCKED session shows the
* placeholder, consistent with incoming messages. Falls back to the
* placeholder when the plaintext isn't cached (sent from another
* device / cache cleared).
* • IN-MEMORY ONLY (forward secrecy) — the cache is a plain Map and
* chatService NEVER touches localStorage/sessionStorage/indexedDB, so
* plaintext is never written to disk; the on-disk / on-chain PFS
* guarantee is unchanged.
* • CLEARED ON LOCK + SIGN-OUT — identity.ts lockSession() AND reset()
* both clear the cache so message content never lingers in memory
* past a lock, and one account's plaintext never leaks into another's
* session.
* • BOUNDED — a cap with oldest-eviction keeps a long reload-free
* session from growing the cache without bound.
*
* Usage:
* cd apps/web && npx tsx scripts/chat-own-sent-plaintext-cache-smoke.ts
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const REPO = join(import.meta.dirname, '..');
interface Scenario {
readonly name: string;
readonly file: string;
readonly mustHave: readonly string[];
readonly mustNotHave?: readonly string[];
}
const SVC = 'src/lib/chat/chatService.ts';
const IDENT = 'src/lib/stores/identity.ts';
const SCENARIOS: readonly Scenario[] = [
{
name: '1 — chatService declares the in-memory own-sent cache + primitives + exported clear',
file: SVC,
mustHave: [
'const OWN_SENT_CACHE_MAX = 1000;',
'const ownSentPlaintext = new Map<string, string>();',
'function ownSentKey(me: string, clientTag: string): string {',
'function rememberOwnSent(me: string, clientTag: string, plaintext: string): void {',
'export function clearOwnSentPlaintextCache(): void {',
'ownSentPlaintext.clear();'
]
},
{
name: '2 — cache is bounded (oldest-eviction over the cap)',
file: SVC,
mustHave: ['if (ownSentPlaintext.size > OWN_SENT_CACHE_MAX) {', 'ownSentPlaintext.keys().next().value']
},
{
name: '3 — sendMessage remembers OUR plaintext keyed by (me, clientTag)',
file: SVC,
mustHave: [
'const clientTag = deps.generateClientTag();',
'rememberOwnSent(deps.me, clientTag, trimmed);'
]
},
{
name: '4 — our-own-sent merge branch restores from cache, GATED on getLiveIdentity(), placeholder fallback',
file: SVC,
mustHave: [
'const ownTag = clientTagFromHeader(rec.header);',
'deps.getLiveIdentity() !== null && ownTag !== null',
'ownSentPlaintext.get(ownSentKey(deps.me, ownTag))',
'text: ownFromChain ?? ownCached ?? ENCRYPTED_PLACEHOLDER'
],
mustNotHave: [
// Pre-fix: our own historical messages were ALWAYS placeholder.
// The literal placeholder-only push in this branch is gone.
'clientTag: clientTagFromHeader(rec.header),\n\t\t\t\t\ttext: ENCRYPTED_PLACEHOLDER,'
]
},
{
name: '5 — IN-MEMORY ONLY: chatService never persists to disk (forward secrecy preserved)',
file: SVC,
mustHave: ['const ownSentPlaintext = new Map<string, string>();'],
mustNotHave: ['localStorage', 'sessionStorage', 'indexedDB']
},
{
name: '6 — identity.ts lockSession() clears the cache on lock (memory hygiene)',
file: IDENT,
mustHave: [
'the own-sent plaintext cache IS cleared here',
"void import('$lib/chat/chatService').then((m) => m.clearOwnSentPlaintextCache());"
]
},
{
name: '7 — identity.ts reset() clears the cache on sign-out / device-leave',
file: IDENT,
mustHave: [
'clear the own-sent plaintext cache (message content must',
"void import('$lib/chat/chatService').then((m) => m.clearOwnSentPlaintextCache());"
]
}
];
let failures = 0;
let scenarios = 0;
function check(s: Scenario): void {
scenarios++;
const path = join(REPO, s.file);
let body: string;
try {
body = readFileSync(path, 'utf8');
} catch (err) {
failures++;
console.log(`${s.name}`);
console.log(` could not read ${s.file}: ${err instanceof Error ? err.message : err}`);
return;
}
const missing = s.mustHave.filter((m) => !body.includes(m));
const regressed = (s.mustNotHave ?? []).filter((m) => body.includes(m));
if (missing.length === 0 && regressed.length === 0) {
console.log(`${s.name}`);
return;
}
failures++;
console.log(`${s.name}`);
if (missing.length > 0) {
console.log(` missing sentinel(s):`);
for (const m of missing) console.log(` - ${JSON.stringify(m)}`);
}
if (regressed.length > 0) {
console.log(` regressed sentinel(s) (pre-fix pattern reappeared):`);
for (const m of regressed) console.log(` - ${JSON.stringify(m)}`);
}
}
console.log('chat-own-sent-plaintext-cache smoke:\n');
for (const s of SCENARIOS) check(s);
console.log(`\n${scenarios} scenarios, ${failures} failed`);
if (failures > 0) {
console.error('chat-own-sent-plaintext-cache-smoke FAILED');
process.exit(1);
}
// Canonical success line — run-smokes.sh greps for `^✓ all` to tally.
console.log(`✓ all ${SCENARIOS.length} chat-own-sent-plaintext-cache scenarios passed`);