cross-impl: live Python-publishes -> TS-restores relay round trip

Phase F starts the in-process NIP-01 relay, has the real Python NostrClient
publish a chunked snapshot to it, then restores that snapshot with the TS
CLI on a clean profile and checks the secrets survive.

It immediately caught a genuine restore bug: Python's index files wrap the
ciphertext in a kdf/ct JSON envelope, and a published snapshot carries
whatever the local file held. TS decrypted the fetched bytes directly, so
it failed on anything Python published -- invisible to the TS-only tests
because TS writes bare ciphertext. Restore (and delta replay) now parse the
wrapper first, exactly as decrypt_and_save_index_from_nostr does.

Regression test in relay.test.ts publishes a wrapper-format payload through
a live relay and restores it.

Cutover plan updated: gate 4 (sync protocol) now cites this end-to-end
evidence, and gate 5's remaining list drops SSH and migrations.

171 core + 46 CLI tests green; 19/19 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 16:24:56 -04:00
1 parent 1eee053ff0
commit 3ebaced2e3
5 files changed
+173 -11

No files matched your search

+10 -2
View File
@@ -37,6 +37,7 @@ import {
fetchLatestSnapshot,
fetchDeltasSince,
decryptPayload,
parseEncryptedFile,
mergeIndexPayloads,
parseVaultIndex,
sha256Hex,
@@ -813,8 +814,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
const fetched = await fetchLatestSnapshot(pool, keys.privateKeyHex);
if (!fetched) throw new Error("no snapshot found on the configured relays");
const indexKey = deriveIndexKeyBytes(mnemonic);
// A published snapshot carries whatever the local index file held.
// Python's index files wrap the ciphertext in a kdf/ct JSON envelope
// (TS writes bare ciphertext), so parse the wrapper first — exactly
// as Python's decrypt_and_save_index_from_nostr does.
const snapshotPayload = parseEncryptedFile(fetched.encrypted).ciphertext;
let state = JSON.parse(
new TextDecoder().decode(await decryptPayload(indexKey, fetched.encrypted)),
new TextDecoder().decode(await decryptPayload(indexKey, snapshotPayload)),
) as Record<string, unknown>;
let deltaCount = 0;
@@ -826,7 +832,9 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
);
for (const delta of deltas) {
const incoming = JSON.parse(
new TextDecoder().decode(await decryptPayload(indexKey, delta)),
new TextDecoder().decode(
await decryptPayload(indexKey, parseEncryptedFile(delta).ciphertext),
),
);
state = mergeIndexPayloads(state, incoming, sha256Hex(delta).slice(0, 16));
deltaCount++;