From c6cb85e840dfd50674c8f6dfdbb2348216d4f17d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Wed, 19 Aug 2026 11:13:15 -0400 Subject: [PATCH] feat(vault): port index0/atlas, and keep it current on every mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index0 is the per-vault activity ledger under `_system.index0`: an append-only event stream per writer, daily checkpoints rolling those events up, and derived canonical views (children_of, counts_by_kind, recent_activity). It was the last piece carried through verbatim rather than computed — which was safe for round-tripping but meant a TypeScript client never UPDATED it. A vault written by this port went stale the moment it was touched, and a Python client reading it afterwards saw an activity log missing everything the TS client had done. Worse, `nostr restore` kept the local block verbatim, so a restore silently discarded the remote's history. Hash parity is the entire contract here: event ids, integrity hashes, checkpoint summaries and view hashes are SHA-256 over Python's canonical JSON, and the merge picks winners by comparing those hashes — one byte of encoding disagreement means the two implementations permanently disagree about which record wins. Everything routes through the existing canonicalJson (Python's ensure_ascii escaping, code-point key ordering, CPython number formatting) rather than JSON.stringify, and the tests compare whole structures via canonical JSON rather than toEqual, so they pass only if the bytes that get hashed are identical. Verified against Python fixtures covering the append chain, compaction, manifest metadata, views and a two-replica merge — including non-ASCII labels and tags, a non-numeric entry id, two writers across two days, and a managed-account scope path. Two Python quirks are replicated deliberately rather than "fixed", because diverging would change which events exist rather than merely how they hash: `str(None)` is "None" (so a JSON null in a required field is a non-empty string there), and a stored integrity_hash is preserved even when it disagrees — this normalizes, it does not verify, and silently rewriting a mismatched hash would hide the tampering the hash exists to reveal. Events are emitted by DIFFING the index at the single mutation funnel each surface goes through, rather than from each call site as Python does. That is deliberate: a call-site emitter is something a new code path can forget, and a silently incomplete ledger is worse than an absent one because it reads as "nothing happened". The event vocabulary matches Python's, so a stream from either implementation reads the same. Emission is best-effort — the atlas is derived state, and it must never fail a committed vault write. mergeIndexPayloads now defaults to merging index0 with the same deterministic rules; "preserve-current" and "reject" remain for callers that want them, and `nostr restore` no longer discards the remote's history. Also made gen_high_risk deterministic. Fernet embeds a random IV and the current time, so that fixture regenerated differently every run and would have failed the fixture-drift CI job each time — it now pins both, with a note that production must never do this. Verified end to end: the TS binary performed four mutations, and Python read the resulting atlas, normalized it without dropping a record, and compacted it into checkpoints and views. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN --- js/packages/cli/src/api/routes.ts | 14 + js/packages/cli/src/capabilities.ts | 14 +- js/packages/cli/src/program.ts | 49 +- js/packages/cli/src/tui/menus.ts | 15 +- js/packages/core/src/index.ts | 36 + js/packages/core/src/sync/canonical.ts | 2 +- js/packages/core/src/sync/merge.ts | 36 +- js/packages/core/src/vault/index0.ts | 1043 ++++++++++ js/packages/core/src/vault/index0Events.ts | 118 ++ js/packages/core/test/index0.test.ts | 242 +++ .../test-vectors/fixtures/high_risk.json | 4 +- js/packages/test-vectors/fixtures/index0.json | 1708 +++++++++++++++++ .../test-vectors/fixtures/manifest.json | 3 +- js/packages/test-vectors/src/index.ts | 15 + scripts/generate_ts_port_fixtures.py | 128 +- 15 files changed, 3396 insertions(+), 31 deletions(-) create mode 100644 js/packages/core/src/vault/index0.ts create mode 100644 js/packages/core/src/vault/index0Events.ts create mode 100644 js/packages/core/test/index0.test.ts create mode 100644 js/packages/test-vectors/fixtures/index0.json diff --git a/js/packages/cli/src/api/routes.ts b/js/packages/cli/src/api/routes.ts index 1c5514f8..8f955596 100644 --- a/js/packages/cli/src/api/routes.ts +++ b/js/packages/cli/src/api/routes.ts @@ -39,6 +39,7 @@ import { removeLink, restoreEntry, isPartitionStub, + emitEntryEvents, splitSecret, recoverSecret, buildSemanticRecords, @@ -183,7 +184,20 @@ async function mutate( const mnemonic = requireUnlocked(ctx); const result = await withVaultLock(path, async () => { const vault = await openVault(path, mnemonic); + // Snapshot before mutating so the index0 differ sees what changed. + const before = structuredClone(vault.index.entries) as Record; const out = await fn(vault.index); + try { + const updated = emitEntryEvents(vault.index, { + before, + after: vault.index.entries as unknown as Record, + fingerprintDir: profileDir(ctx), + now: Math.floor(ctx.now() / 1000), + }); + (vault.index as unknown as Record)["_system"] = updated["_system"]; + } catch { + // Derived state; never fail a committed write over it. + } await saveVaultHoldingLock(vault); return out; }); diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index 68761f40..fa61f3bb 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -136,6 +136,17 @@ export function capabilities(): Record { // letting the job run under rules nobody reviewed it against. policy_binding: "sha256 of the canonical agent_policy.json at creation", }, + index0: { + // The per-vault activity ledger under _system.index0: an append-only + // event stream per writer, daily checkpoints, and derived views. + events: "appended automatically on every vault mutation (CLI, TUI, API)", + event_types: ["entry_created", "entry_updated", "entry_deleted"], + canonical_views: ["children_of", "counts_by_kind", "recent_activity"], + // Never synced: derived from data that does not leave the machine. + local_only_views: ["conversation_index", "hot_nodes", "semantic_neighbors"], + merge: "deterministic; events union by id, the rest resolve by timestamp then hash", + interop: "hashes and merge results byte-identical to the Python implementation", + }, audit: { commands: ["agent audit-verify", "agent audit-tail"], chain: "HMAC-SHA256(prev_sig + canonical_payload), keyed by KEY_INDEX", @@ -150,9 +161,6 @@ export function capabilities(): Record { // reproducible, so a TS implementation would derive a DIFFERENT key // from the same seed. Refusing beats silently diverging. "pgp RSA keys (ed25519 is at byte parity; RSA generation is not reproducible)", - // Python-derived state, recomputed on load. Carried through verbatim by - // the TS port so a Python profile round-trips, but not computed here. - "index0/atlas (preserved verbatim on read/write, never recomputed)", // Deliberate: the TS interactive mode follows Python's legacy v1 menu // tree. See docs/tui_v2_cutover_decision.md. "Python's v2/v3 Textual TUIs (interactive mode follows the legacy v1 menus)", diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 34eb27b6..b80da904 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -30,6 +30,7 @@ import { splitSecret, recoverSecret, isPartitionStub, + emitEntryEvents, partitionStub, type Entry, buildSemanticRecords, @@ -324,7 +325,11 @@ async function mutateVault( const mnemonic = await resolveMnemonic(app, opts); return withVaultLock(path, async () => { const vault = await openVault(path, mnemonic); + // Snapshot before mutating so the index0 differ can see what changed. + // Structured-cloned, because `fn` mutates the index in place. + const before = structuredClone(vault.index.entries) as Record; const result = await fn(vault); + recordIndex0(vault, before, dirname(path)); await saveVaultHoldingLock(vault); // Rolling snapshot, after the write and still under the lock, matching // Python's create_backup() call order. Best-effort by design: a failed @@ -397,6 +402,31 @@ function resolveFromRows( throw new Error(`no entry matches "${refOrQuery}"`); } +/** + * Append index0 events for whatever a mutation changed. + * + * Best-effort: the atlas is derived state and can be rebuilt, so a failure + * here must never fail a committed vault write. Silence in the ledger is a + * lesser harm than refusing to save the user's entry. + */ +function recordIndex0( + vault: OpenedVault, + before: Record, + fingerprintDir: string, +): void { + try { + const updated = emitEntryEvents(vault.index, { + before, + after: vault.index.entries as unknown as Record, + fingerprintDir, + now: Math.floor(Date.now() / 1000), + }); + (vault.index as unknown as Record)["_system"] = updated["_system"]; + } catch { + // Deliberately swallowed; see the note above. + } +} + async function openReadAccess(opts: GlobalOpts): Promise { const token = process.env["SEEDPASS_TOKEN"]; const app = new AppDir(resolveAppDir(opts.appDir)); @@ -1429,13 +1459,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { await decryptPayload(indexKey, parseEncryptedFile(delta).ciphertext), ), ); - // preserve-current here too: the default would refuse any - // Python profile that carries atlas state as soon as one delta - // exists. // Same collector as the final merge: a delta can just as easily // carry an entry that displaces one the snapshot created. + // + // index0 now merges properly. It used to be preserve-current + // because the atlas could not be recomputed here, which meant a + // delta's activity history was discarded on the way in. remote = mergeIndexPayloads(remote, incoming, sha256Hex(delta).slice(0, 16), { - index0: "preserve-current", report, }); deltaCount++; @@ -1464,13 +1494,10 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { final = remote; mode = local === null ? "restored" : "replaced"; } else { - // Keep the local _system.index0 verbatim: it is Python-derived - // atlas state recomputed on load, and refusing to merge it would - // block every restore into a Python-created profile. - final = mergeIndexPayloads(local, remote, "nostr-restore", { - index0: "preserve-current", - report, - }); + // index0 merges both sides now. It used to keep the local block + // verbatim, because the atlas could not be merged here — which + // meant a restore silently discarded the remote's activity history. + final = mergeIndexPayloads(local, remote, "nostr-restore", { report }); mode = "merged"; } diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts index ec684bd8..a7951df5 100644 --- a/js/packages/cli/src/tui/menus.ts +++ b/js/packages/cli/src/tui/menus.ts @@ -48,7 +48,7 @@ import { atomicWrite, openVault, saveVaultHoldingLock, withVaultLock, type Opene import { entryMetadata, refFor } from "../refs.js"; import { materializeSecret, type MaterializedSecret } from "../secrets.js"; import { clipboardSink } from "../sinks.js"; -import { encodeQr, renderQrText } from "@seedpass/core"; +import { encodeQr, renderQrText, emitEntryEvents } from "@seedpass/core"; import { createIndexBackup, type BackupResult } from "../backups.js"; import { loadConfig, @@ -168,7 +168,20 @@ async function mutate(s: Session, fn: (vault: OpenedVault) => void): Promise { const fresh = await openVault(path, mnemonic); + // Snapshot before mutating so the index0 differ sees what changed. + const before = structuredClone(fresh.index.entries) as Record; fn(fresh); + try { + const updated = emitEntryEvents(fresh.index, { + before, + after: fresh.index.entries as unknown as Record, + fingerprintDir: s.app.profileDir(s.fingerprint), + now: Math.floor(s.clock() / 1000), + }); + (fresh.index as unknown as Record)["_system"] = updated["_system"]; + } catch { + // Derived state; never fail a committed write over it. + } await saveVaultHoldingLock(fresh); s.vault = fresh; try { diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index d82a50f9..0371c3da 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -112,6 +112,42 @@ export { MAX_TOTAL_SHARES, type ParsedShare, } from "./vault/recoverySplit.js"; +export { + normalizeIndex0, + normalizeIndex0Event, + normalizeIndex0Checkpoint, + normalizeCanonicalView, + normalizeHead, + normalizeViewManifest, + ensureIndex0Payload, + deriveIndex0Context, + makeIndex0Event, + appendIndex0Event, + buildDailyCheckpoint, + rebuildIndex0Checkpoints, + compactIndex0, + compactIndex0Payload, + rebuildCanonicalViewsPayload, + buildManifestIndex0Metadata, + listCanonicalViews, + getCanonicalView, + mergeSystemIndex0, + recomputeIndex0Stats, + // Aliased: `computeEventId` is already taken by the Nostr event module, + // and the two hash entirely different things. + computeEventHash as computeIndex0EventHash, + computeEventId as computeIndex0EventId, + computeHeadHash as computeIndex0HeadHash, + computeCheckpointHash as computeIndex0CheckpointHash, + computeViewHash as computeIndex0ViewHash, + INDEX0_SCHEMA_VERSION, + INDEX0_CANONICAL_VIEW_TYPES, + LOCAL_ONLY_VIEW_TYPES, + INDEX0_MAX_CHECKPOINTS_PER_WRITER, + type Index0, + type Index0Context, +} from "./vault/index0.js"; +export { emitEntryEvents, type EmitOptions } from "./vault/index0Events.js"; export { encodeQr, renderQrText, EC_LEVELS, type QrMatrix, type EcLevel } from "./util/qr.js"; export { buildSemanticRecords, diff --git a/js/packages/core/src/sync/canonical.ts b/js/packages/core/src/sync/canonical.ts index 624fcc1e..221e4745 100644 --- a/js/packages/core/src/sync/canonical.ts +++ b/js/packages/core/src/sync/canonical.ts @@ -94,7 +94,7 @@ function pythonFloatRepr(n: number): string { } /** Code-point ordering, matching Python's sorted() on str. */ -function compareCodePoints(a: string, b: string): number { +export function compareCodePoints(a: string, b: string): number { const ai = Array.from(a); const bi = Array.from(b); const len = Math.min(ai.length, bi.length); diff --git a/js/packages/core/src/sync/merge.ts b/js/packages/core/src/sync/merge.ts index 97ec3882..a7901e56 100644 --- a/js/packages/core/src/sync/merge.ts +++ b/js/packages/core/src/sync/merge.ts @@ -13,6 +13,7 @@ */ import { canonicalHash, canonicalJson } from "./canonical.js"; +import { mergeSystemIndex0 } from "../vault/index0.js"; export const TOMBSTONE_RETENTION_CAP = 2048; export const MERGE_STRATEGY = "modified_ts_hash_tombstone_v2"; @@ -264,10 +265,12 @@ function assertIndex0Portable(raw: unknown): void { function ensureIndex0Payload(data: unknown, options: MergeOptions = {}): Dict { const out: Dict = isDict(data) ? { ...data } : {}; const system: Dict = isDict(out["_system"]) ? { ...(out["_system"] as Dict) } : {}; - if (options.index0 !== "preserve-current") { + if (options.index0 === "reject") { assertIndex0Portable(system["index0"]); system["index0"] = emptyIndex0(); } + // "merge" and "preserve-current" both keep the block; which side wins is + // decided in mergeIndexPayloads, where both are in hand. out["_system"] = system; return out; } @@ -332,15 +335,20 @@ export interface MergeOptions { report?: MergeReport; /** - * What to do with `_system.index0`, Python's derived atlas state. + * What to do with `_system.index0`, the derived atlas state. * - * Default ("reject") refuses to merge populated index0 rather than drop - * it silently. "preserve-current" keeps the current side's block verbatim - * and ignores the incoming one — correct when merging remote state into a - * live profile, since Python recomputes canonical views from entries on - * load and would otherwise be blocked entirely. + * Default ("merge") merges both sides with `mergeSystemIndex0`, the same + * deterministic rules Python uses: events union by id, checkpoints, views + * and heads resolve by timestamp then by their own hash. That is now the + * right default because index0 is ported; before it was, the only safe + * choices were to refuse or to keep one side. + * + * "preserve-current" keeps the current side's block verbatim and ignores + * the incoming one. "reject" refuses to merge a populated index0 at all, + * rather than dropping it silently — kept for callers that would rather + * fail than have derived state change under them. */ - index0?: "reject" | "preserve-current"; + index0?: "merge" | "reject" | "preserve-current"; } function describeEntry(entry: Dict): { kind: string; label: string } { @@ -549,8 +557,18 @@ export function mergeIndexPayloads( out["_sync_meta"] = meta; const outSystem: Dict = isDict(out["_system"]) ? (out["_system"] as Dict) : {}; - if (options.index0 !== "preserve-current") { + const mode = options.index0 ?? "merge"; + if (mode === "reject") { + // Populated blocks were already refused above; anything reaching here is + // empty, and is normalized so the output shape is stable. outSystem["index0"] = emptyIndex0(); + } else if (mode === "merge") { + // Both sides' derived state, merged by the same deterministic rules + // Python uses. Doing this from the ORIGINAL payloads rather than from + // `out` matters: `out` has already had entries merged into it, and + // index0 records what each writer did, not what the merge concluded. + const incomingSystem = isDict(incoming["_system"]) ? (incoming["_system"] as Dict) : {}; + outSystem["index0"] = mergeSystemIndex0(outSystem["index0"], incomingSystem["index0"]); } out["_system"] = outSystem; return out; diff --git a/js/packages/core/src/vault/index0.ts b/js/packages/core/src/vault/index0.ts new file mode 100644 index 00000000..37ca04fc --- /dev/null +++ b/js/packages/core/src/vault/index0.ts @@ -0,0 +1,1043 @@ +/** + * index0 / atlas — parity with src/seedpass/core/index0.py. + * + * A per-vault activity ledger that lives inside the encrypted index under + * `_system.index0`. It holds an append-only stream of events per writer, + * daily checkpoints rolling those events up, and derived "canonical views" + * (children, counts by kind, recent activity) that other tooling reads + * instead of walking the whole vault. + * + * Everything here is DERIVED state: it can be rebuilt from the entries and + * the event stream. That is why the port could safely carry it verbatim + * before now — but carrying it verbatim meant a TypeScript client never + * updated it, so a vault written by this port went stale the moment it was + * touched, and a Python client reading it afterwards saw an activity log + * missing everything the TS client had done. + * + * HASH PARITY IS THE WHOLE CONTRACT. Event ids, integrity hashes, checkpoint + * summaries and view hashes are all SHA-256 over Python's canonical JSON, and + * the merge picks winners by comparing those hashes. A single byte of + * disagreement in the encoding means the two implementations disagree about + * which record wins, permanently. Everything routes through `canonicalJson` + * (which already handles Python's ensure_ascii escaping, code-point key + * ordering and number formatting) rather than JSON.stringify. + */ + +import { canonicalJson, canonicalHash, compareCodePoints } from "../sync/canonical.js"; + +export const INDEX0_SCHEMA_VERSION = 1; +export const INDEX0_CHECKPOINT_SUBJECT_CAP = 64; +export const INDEX0_MAX_CHECKPOINTS_PER_WRITER = 30; +export const INDEX0_MANIFEST_CHECKPOINT_LIMIT = 32; +export const INDEX0_RECENT_ACTIVITY_LIMIT = 20; + +/** Views that never leave the machine that built them. */ +export const LOCAL_ONLY_VIEW_TYPES = ["conversation_index", "hot_nodes", "semantic_neighbors"]; +export const INDEX0_CANONICAL_VIEW_TYPES = [ + "children_of", + "counts_by_kind", + "recent_activity", +] as const; + +export type Dict = Record; + +export interface Index0 { + schema_version: number; + events: Record; + checkpoints: Record; + canonical_views: Record; + view_manifest: Dict; + heads: Record; + stats: Dict; +} + +function isDict(value: unknown): value is Dict { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Python's `str(value)`, which is not JavaScript's `String(value)`. + * + * `str(None)` is "None", `str(True)` is "True". Those are quirks rather than + * intentions, but they decide whether a required field counts as present, so + * a vault Python accepted must be accepted here too — and hash identically. + * Diverging would mean the two implementations disagree about which events + * exist, which is worse than agreeing on an odd answer. + */ +function pythonStr(value: unknown): string { + if (value === null || value === undefined) return "None"; + if (value === true) return "True"; + if (value === false) return "False"; + return String(value); +} + +function normalizeString(value: unknown): string { + return pythonStr(value).trim(); +} + +/** + * Python's `int(raw)` with a fallback. + * + * Accepts integers, truncates floats toward zero, and parses decimal strings + * with optional sign, surrounding whitespace and PEP 515 underscores. Anything + * else (including "3.9", "0x10" and NaN) falls back, exactly as the + * `except Exception` in Python does. + */ +function safeInt(raw: unknown, fallback = 0): number { + if (typeof raw === "number") { + if (!Number.isFinite(raw)) return fallback; + return Math.trunc(raw); + } + if (typeof raw === "boolean") return raw ? 1 : 0; + if (typeof raw === "string") { + const text = raw.trim().replace(/_/g, ""); + if (!/^[+-]?\d+$/.test(text)) return fallback; + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; +} + +function normalizeMapping(raw: unknown): Dict { + return isDict(raw) ? { ...raw } : {}; +} + +/** Deduplicated, code-point sorted, blanks dropped — Python's sorted(set(...)). */ +function normalizeTags(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + for (const tag of raw) { + const value = normalizeString(tag); + if (value) seen.add(value); + } + return [...seen].sort(compareCodePoints); +} + +function normalizeLinks(raw: unknown): Dict[] { + if (!Array.isArray(raw)) return []; + // Keyed by canonical form so duplicates collapse, then emitted in key + // order — the same dict-keyed dedupe Python does. + const byKey = new Map(); + for (const item of raw) { + if (!isDict(item)) continue; + const targetId = normalizeString(item["target_id"] ?? ""); + const relation = normalizeString(item["relation"] ?? ""); + const note = normalizeString(item["note"] ?? ""); + if (!targetId || !relation) continue; + const normalized: Dict = { target_id: targetId, relation }; + if (note) normalized["note"] = note; + byKey.set(canonicalJson(normalized), normalized); + } + return [...byKey.keys()].sort(compareCodePoints).map((key) => byKey.get(key)!); +} + +// ------------------------------------------------------------------ hashes + +/** Hash of an event, excluding its own integrity hash. */ +export function computeEventHash(event: Dict): string { + const body = { ...event }; + delete body["integrity_hash"]; + return canonicalHash(body); +} + +export function computeEventId(event: Dict): string { + return `e:${computeEventHash(event)}`; +} + +/** The per-writer head marker: what the next event chains onto. */ +export function computeHeadHash(event: Dict): string { + return canonicalHash({ + event_id: normalizeString(event["event_id"] ?? ""), + integrity_hash: normalizeString(event["integrity_hash"] ?? ""), + prev_hash: normalizeString(event["prev_hash"] ?? ""), + writer_id: normalizeString(event["writer_id"] ?? ""), + }); +} + +export function computeCheckpointHash(checkpoint: Dict): string { + const body = { ...checkpoint }; + delete body["summary_hash"]; + return canonicalHash(body); +} + +export function computeViewHash(view: Dict): string { + const body = { ...view }; + delete body["view_hash"]; + return canonicalHash(body); +} + +/** UTC calendar day, the checkpoint window key. */ +function windowKeyForTs(ts: number): string { + return new Date(safeInt(ts) * 1000).toISOString().slice(0, 10); +} + +function eventSortKey(event: Dict): [number, string] { + return [safeInt(event["modified_ts"] ?? 0), normalizeString(event["event_id"] ?? "")]; +} + +function compareEvents(a: Dict, b: Dict): number { + const [ats, aid] = eventSortKey(a); + const [bts, bid] = eventSortKey(b); + return ats - bts || compareCodePoints(aid, bid); +} + +// ------------------------------------------------------------- normalizers + +export function normalizeIndex0Event(raw: unknown): Dict | null { + if (!isDict(raw)) return null; + const modifiedTs = safeInt(raw["modified_ts"] ?? 0); + // A timestamp is what orders the stream; an event without one cannot be + // placed, so it is dropped rather than guessed at. + if (modifiedTs <= 0) return null; + + const event: Dict = { + event_type: normalizeString(raw["event_type"] ?? ""), + subject_type: normalizeString(raw["subject_type"] ?? ""), + subject_id: normalizeString(raw["subject_id"] ?? ""), + subject_kind: normalizeString(raw["subject_kind"] ?? ""), + scope_path: normalizeString(raw["scope_path"] ?? ""), + actor_type: normalizeString(raw["actor_type"] ?? ""), + actor_id: normalizeString(raw["actor_id"] ?? ""), + writer_id: normalizeString(raw["writer_id"] ?? ""), + modified_ts: modifiedTs, + prev_hash: normalizeString(raw["prev_hash"] ?? ""), + classification: normalizeString(raw["classification"] ?? "internal") || "internal", + partition: normalizeString(raw["partition"] ?? "standard") || "standard", + payload_ref: normalizeMapping(raw["payload_ref"]), + links: normalizeLinks(raw["links"]), + tags: normalizeTags(raw["tags"]), + visibility: normalizeString(raw["visibility"] ?? "private") || "private", + }; + // Optional fields are present only when non-empty, because their presence + // changes the hash. + for (const key of ["policy_ref", "source", "source_event_id", "summary"]) { + const value = normalizeString(raw[key] ?? ""); + if (value) event[key] = value; + } + if ( + !event["event_type"] || + !event["subject_type"] || + !event["subject_id"] || + !event["writer_id"] + ) { + return null; + } + + event["integrity_hash"] = normalizeString(raw["integrity_hash"] ?? ""); + const expected = computeEventHash(event); + // A stored hash is preserved even when it disagrees: this normalizes, it + // does not verify, and silently rewriting a mismatched hash would hide + // exactly the tampering the hash exists to reveal. + if (!event["integrity_hash"]) event["integrity_hash"] = expected; + event["event_id"] = normalizeString(raw["event_id"] ?? "") || `e:${expected}`; + return event; +} + +export function normalizeIndex0Checkpoint(raw: unknown): Dict | null { + if (!isDict(raw)) return null; + const checkpointId = normalizeString(raw["checkpoint_id"] ?? ""); + const writerId = normalizeString(raw["writer_id"] ?? ""); + const windowType = normalizeString(raw["window_type"] ?? ""); + const windowKey = normalizeString(raw["window_key"] ?? ""); + const modifiedTs = safeInt(raw["modified_ts"] ?? 0); + if (!checkpointId || !writerId || !windowType || !windowKey || modifiedTs <= 0) { + return null; + } + + const rollup = normalizeMapping(raw["rollup"]); + const countMap = (source: unknown): Dict => { + const out: Dict = {}; + for (const key of Object.keys(normalizeMapping(source)).sort(compareCodePoints)) { + const name = normalizeString(key); + if (name) out[name] = safeInt(normalizeMapping(source)[key] ?? 0); + } + return out; + }; + + const checkpoint: Dict = { + checkpoint_id: checkpointId, + window_type: windowType, + window_key: windowKey, + writer_id: writerId, + window_start_ts: safeInt(raw["window_start_ts"] ?? 0), + window_end_ts: safeInt(raw["window_end_ts"] ?? 0), + event_count: Math.max(0, safeInt(raw["event_count"] ?? 0)), + head_hash: normalizeString(raw["head_hash"] ?? ""), + rollup: { + events_by_type: countMap(rollup["events_by_type"]), + subjects_by_kind: countMap(rollup["subjects_by_kind"]), + subjects: normalizeTags(rollup["subjects"]), + }, + modified_ts: modifiedTs, + }; + checkpoint["summary_hash"] = + normalizeString(raw["summary_hash"] ?? "") || computeCheckpointHash(checkpoint); + return checkpoint; +} + +export function normalizeCanonicalView( + raw: unknown, + options: { viewManifest?: Dict | null } = {}, +): Dict | null { + if (!isDict(raw)) return null; + const viewType = normalizeString(raw["view_type"] ?? ""); + const modifiedTs = safeInt(raw["modified_ts"] ?? 0); + if (!viewType || modifiedTs <= 0) return null; + // Local-only views must never be written into a shared vault: they are + // derived from data that does not leave the machine, so syncing them would + // publish inferences about it. + if (LOCAL_ONLY_VIEW_TYPES.includes(viewType)) return null; + const manifest = options.viewManifest; + if (manifest) { + const localOnly = manifest["local_only_view_types"]; + if (Array.isArray(localOnly) && localOnly.map(String).includes(viewType)) return null; + } + + const view: Dict = { + view_id: normalizeString(raw["view_id"] ?? ""), + view_type: viewType, + scope_path: normalizeString(raw["scope_path"] ?? ""), + source_checkpoint_ids: normalizeTags(raw["source_checkpoint_ids"]), + source_event_ids: normalizeTags(raw["source_event_ids"]), + data: normalizeMapping(raw["data"]), + modified_ts: modifiedTs, + }; + if (!view["view_id"]) return null; + view["view_hash"] = normalizeString(raw["view_hash"] ?? "") || computeViewHash(view); + return view; +} + +export function normalizeHead(raw: unknown): Dict | null { + if (!isDict(raw)) return null; + const eventId = normalizeString(raw["event_id"] ?? ""); + const headHash = normalizeString(raw["head_hash"] ?? ""); + const modifiedTs = safeInt(raw["modified_ts"] ?? 0); + if (!eventId || !headHash || modifiedTs <= 0) return null; + return { event_id: eventId, head_hash: headHash, modified_ts: modifiedTs }; +} + +export function normalizeViewManifest(raw: unknown): Dict { + const data = normalizeMapping(raw); + const stringSet = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + const seen = new Set(); + for (const item of value) { + const text = normalizeString(item); + if (text) seen.add(text); + } + return [...seen].sort(compareCodePoints); + }; + const builderVersions: Dict = {}; + const rawVersions = data["builder_versions"]; + if (isDict(rawVersions)) { + for (const key of Object.keys(rawVersions).sort(compareCodePoints)) { + const name = normalizeString(key); + if (name) builderVersions[name] = safeInt(rawVersions[key] ?? 1, 1); + } + } + return { + version: Math.max(1, safeInt(data["version"] ?? 1, 1)), + canonical_view_types: stringSet(data["canonical_view_types"]), + local_only_view_types: stringSet(data["local_only_view_types"]), + builder_versions: builderVersions, + }; +} + +export function recomputeIndex0Stats(index0: Dict): Dict { + const events = normalizeMapping(index0["events"]); + const checkpoints = normalizeMapping(index0["checkpoints"]); + const heads = normalizeMapping(index0["heads"]); + const timestamps = (source: Dict): number[] => + Object.values(source) + .filter(isDict) + .map((value) => safeInt(value["modified_ts"] ?? 0)); + const checkpointTs = timestamps(checkpoints); + const headTs = timestamps(heads); + return { + event_count: Object.keys(events).length, + checkpoint_count: Object.keys(checkpoints).length, + writer_count: Object.keys(heads).length, + last_compaction_ts: checkpointTs.length ? Math.max(...checkpointTs) : 0, + last_validation_ts: headTs.length ? Math.max(...headTs) : 0, + }; +} + +export function normalizeIndex0(raw: unknown): Index0 { + const data = normalizeMapping(raw); + const normalized: Index0 = { + schema_version: Math.max( + INDEX0_SCHEMA_VERSION, + safeInt(data["schema_version"] ?? INDEX0_SCHEMA_VERSION, 1), + ), + events: {}, + checkpoints: {}, + canonical_views: {}, + view_manifest: {}, + heads: {}, + stats: {}, + }; + for (const [eventId, event] of Object.entries(normalizeMapping(data["events"]))) { + const normalizedEvent = normalizeIndex0Event(event); + if (normalizedEvent) normalized.events[String(eventId)] = normalizedEvent; + } + for (const [id, checkpoint] of Object.entries(normalizeMapping(data["checkpoints"]))) { + const normalizedCheckpoint = normalizeIndex0Checkpoint(checkpoint); + if (normalizedCheckpoint) normalized.checkpoints[String(id)] = normalizedCheckpoint; + } + const viewManifest = normalizeViewManifest(data["view_manifest"]); + normalized.view_manifest = viewManifest; + for (const [viewId, view] of Object.entries(normalizeMapping(data["canonical_views"]))) { + const normalizedView = normalizeCanonicalView(view, { viewManifest }); + if (normalizedView) normalized.canonical_views[String(viewId)] = normalizedView; + } + for (const [writerId, head] of Object.entries(normalizeMapping(data["heads"]))) { + const normalizedHead = normalizeHead(head); + if (normalizedHead) normalized.heads[String(writerId)] = normalizedHead; + } + normalized.stats = recomputeIndex0Stats(normalized as unknown as Dict); + return normalized; +} + +// ------------------------------------------------------------------ context + +export interface Index0Context { + actor_type: string; + actor_id: string; + writer_id: string; + scope_path: string; + root_fingerprint: string; + current_fingerprint: string; +} + +/** + * Who is writing, and where in the seed hierarchy. + * + * A managed account lives at `/accounts/`, and its scope path + * names both so views from a child and its parent do not collide. + */ +export function deriveIndex0Context( + fingerprintDir: string, + options: { actorType?: string } = {}, +): Index0Context { + const parts = fingerprintDir.replace(/\/+$/, "").split("/"); + const currentFp = (parts[parts.length - 1] ?? "").trim(); + const parentName = parts[parts.length - 2] ?? ""; + let rootFp = currentFp; + let scopePath = `seed/${currentFp}`; + if (parentName === "accounts") { + rootFp = (parts[parts.length - 3] ?? "").trim(); + scopePath = `seed/${rootFp}/managed/${currentFp}`; + } + return { + actor_type: normalizeString(options.actorType ?? "user") || "user", + actor_id: currentFp, + writer_id: `writer:profile:${currentFp}`, + scope_path: scopePath, + root_fingerprint: rootFp, + current_fingerprint: currentFp, + }; +} + +/** Ensure `_system.index0` exists and is normalized, without touching entries. */ +export function ensureIndex0Payload(data: unknown): Dict { + const out = isDict(data) ? { ...data } : {}; + const system = isDict(out["_system"]) ? { ...(out["_system"] as Dict) } : {}; + system["index0"] = normalizeIndex0(system["index0"]); + out["_system"] = system; + return out; +} + +// ------------------------------------------------------------------- events + +export interface MakeEventOptions { + eventType: string; + subjectType: string; + subjectId: unknown; + subjectKind: string; + modifiedTs: number; + writerId: string; + actorId: string; + scopePath: string; + actorType?: string; + payloadRef?: Dict | null; + links?: unknown[] | null; + tags?: unknown[] | null; + prevHash?: string; + classification?: string; + partition?: string; + visibility?: string; + policyRef?: string; + source?: string; + sourceEventId?: string; + summary?: string; +} + +export function makeIndex0Event(options: MakeEventOptions): Dict { + const event: Dict = { + event_type: normalizeString(options.eventType), + subject_type: normalizeString(options.subjectType), + subject_id: normalizeString(options.subjectId), + subject_kind: normalizeString(options.subjectKind), + scope_path: normalizeString(options.scopePath), + actor_type: normalizeString(options.actorType ?? "user") || "user", + actor_id: normalizeString(options.actorId), + writer_id: normalizeString(options.writerId), + // A zero or negative timestamp would make the event unorderable and + // normalizeIndex0Event would then discard it, so it is floored at 1. + modified_ts: Math.max(1, safeInt(options.modifiedTs, 1)), + prev_hash: normalizeString(options.prevHash ?? ""), + classification: normalizeString(options.classification ?? "internal") || "internal", + partition: normalizeString(options.partition ?? "standard") || "standard", + payload_ref: normalizeMapping(options.payloadRef), + links: normalizeLinks(options.links), + tags: normalizeTags(options.tags), + visibility: normalizeString(options.visibility ?? "private") || "private", + }; + for (const [key, value] of [ + ["policy_ref", options.policyRef], + ["source", options.source], + ["source_event_id", options.sourceEventId], + ["summary", options.summary], + ] as const) { + const normalized = normalizeString(value ?? ""); + if (normalized) event[key] = normalized; + } + event["integrity_hash"] = computeEventHash(event); + event["event_id"] = computeEventId(event); + return normalizeIndex0Event(event) ?? event; +} + +export interface AppendEventOptions extends Omit< + MakeEventOptions, + "writerId" | "actorId" | "scopePath" | "prevHash" +> { + fingerprintDir: string; +} + +/** + * Append an event to the payload's stream and advance the writer's head. + * + * The event chains onto the writer's current head, so a gap or a rewrite is + * detectable: `prev_hash` names what this writer last wrote. + */ +export function appendIndex0Event(payload: unknown, options: AppendEventOptions): Dict { + const out = ensureIndex0Payload(payload); + const system = (out["_system"] as Dict)["index0"] as unknown as Index0; + const context = deriveIndex0Context(options.fingerprintDir, { + ...(options.actorType !== undefined && { actorType: options.actorType }), + }); + const head = system.heads[context.writer_id]; + const prevHash = isDict(head) ? normalizeString(head["head_hash"] ?? "") : ""; + + const event = makeIndex0Event({ + ...options, + writerId: context.writer_id, + actorId: context.actor_id, + scopePath: context.scope_path, + actorType: context.actor_type, + prevHash, + }); + + system.events[String(event["event_id"])] = event; + system.heads[context.writer_id] = { + event_id: event["event_id"] as string, + head_hash: computeHeadHash(event), + modified_ts: event["modified_ts"] as number, + }; + system.stats = recomputeIndex0Stats(system as unknown as Dict); + return out; +} + +// -------------------------------------------------------------- checkpoints + +export function buildDailyCheckpoint( + writerId: string, + windowKey: string, + events: unknown[], +): Dict | null { + const ordered = events + .map((event) => normalizeIndex0Event(event)) + .filter((event): event is Dict => event !== null) + .sort(compareEvents); + if (ordered.length === 0) return null; + + const latest = ordered[ordered.length - 1]!; + const eventsByType: Record = {}; + const subjectsByKind: Record = {}; + const subjectSet = new Set(); + for (const event of ordered) { + const subjectId = normalizeString(event["subject_id"] ?? ""); + if (subjectId) subjectSet.add(subjectId); + const eventType = normalizeString(event["event_type"] ?? ""); + const subjectKind = normalizeString(event["subject_kind"] ?? ""); + if (eventType) eventsByType[eventType] = (eventsByType[eventType] ?? 0) + 1; + if (subjectKind) subjectsByKind[subjectKind] = (subjectsByKind[subjectKind] ?? 0) + 1; + } + // Capped so one busy day cannot make a checkpoint unbounded. + const subjects = [...subjectSet].sort(compareCodePoints).slice(0, INDEX0_CHECKPOINT_SUBJECT_CAP); + + const sortedCounts = (source: Record): Dict => { + const out: Dict = {}; + for (const key of Object.keys(source).sort(compareCodePoints)) out[key] = source[key]!; + return out; + }; + + const checkpoint: Dict = { + checkpoint_id: `cp:day:${windowKey}:${writerId}`, + window_type: "day", + window_key: windowKey, + writer_id: writerId, + window_start_ts: safeInt(ordered[0]!["modified_ts"] ?? 0), + window_end_ts: safeInt(latest["modified_ts"] ?? 0), + event_count: ordered.length, + head_hash: computeHeadHash(latest), + rollup: { + events_by_type: sortedCounts(eventsByType), + subjects_by_kind: sortedCounts(subjectsByKind), + subjects, + }, + modified_ts: safeInt(latest["modified_ts"] ?? 0), + }; + checkpoint["summary_hash"] = computeCheckpointHash(checkpoint); + return normalizeIndex0Checkpoint(checkpoint); +} + +export function rebuildIndex0Checkpoints( + index0: Dict, + options: { maxCheckpointsPerWriter?: number } = {}, +): Record { + const maxPerWriter = options.maxCheckpointsPerWriter ?? INDEX0_MAX_CHECKPOINTS_PER_WRITER; + const grouped = new Map(); + for (const event of Object.values(normalizeMapping(index0["events"]))) { + const normalized = normalizeIndex0Event(event); + if (!normalized) continue; + const writerId = normalizeString(normalized["writer_id"] ?? ""); + if (!writerId) continue; + const windowKey = windowKeyForTs(safeInt(normalized["modified_ts"] ?? 0)); + // The key joins on a character that cannot appear in a date, so a writer + // id containing the separator cannot forge a different grouping. + const key = `${windowKey}${writerId}`; + const bucket = grouped.get(key) ?? []; + bucket.push(normalized); + grouped.set(key, bucket); + } + + const checkpoints: Record = {}; + const windowsByWriter = new Map>(); + for (const [key, writerEvents] of grouped) { + const [windowKey, writerId] = key.split("") as [string, string]; + const checkpoint = buildDailyCheckpoint(writerId, windowKey, writerEvents); + if (!checkpoint) continue; + checkpoints[String(checkpoint["checkpoint_id"])] = checkpoint; + const windows = windowsByWriter.get(writerId) ?? new Set(); + windows.add(windowKey); + windowsByWriter.set(writerId, windows); + } + + // Keep the most recent windows per writer, so history is bounded without + // one noisy writer evicting another's checkpoints. + const retained = new Set(); + for (const [writerId, windows] of windowsByWriter) { + const keep = [...windows].sort(compareCodePoints).reverse().slice(0, Math.max(1, maxPerWriter)); + for (const windowKey of keep) retained.add(`cp:day:${windowKey}:${writerId}`); + } + + const out: Record = {}; + for (const id of [...retained].sort(compareCodePoints)) { + if (checkpoints[id]) out[id] = checkpoints[id]!; + } + return out; +} + +export function compactIndex0( + index0: unknown, + options: { maxCheckpointsPerWriter?: number } = {}, +): Index0 { + const normalized = normalizeIndex0(index0); + normalized.checkpoints = rebuildIndex0Checkpoints( + normalized as unknown as Dict, + options, + ); + normalized.stats = recomputeIndex0Stats(normalized as unknown as Dict); + return normalized; +} + +// ------------------------------------------------------------------- views + +function normalizeEntrySummary(entryId: string, entry: Dict): Dict { + const kind = normalizeString(entry["kind"] ?? entry["type"] ?? "password") || "password"; + return { + entry_id: normalizeString(entryId), + kind, + label: normalizeString(entry["label"] ?? entry["website"] ?? ""), + archived: Boolean(entry["archived"] ?? entry["blacklisted"] ?? false), + modified_ts: safeInt(entry["modified_ts"] ?? entry["updated"] ?? 0), + link_count: normalizeLinks(entry["links"] ?? []).length, + tag_count: normalizeTags(entry["tags"] ?? []).length, + }; +} + +/** + * Order entry ids numerically, tolerating ids that are not numbers. + * + * Entry ids are normally decimal strings, but an index that has been hand + * edited, partially restored, or written by another tool can hold anything. + * A malformed id must degrade the ordering, not break view building — for a + * password manager, a vault that will not open is indistinguishable from one + * that is lost. + */ +function compareEntryIds(a: string, b: string): number { + const an = /^[+-]?\d+$/.test(a.trim()) ? Number(a.trim()) : null; + const bn = /^[+-]?\d+$/.test(b.trim()) ? Number(b.trim()) : null; + if (an !== null && bn !== null) return an - bn; + if (an !== null) return -1; + if (bn !== null) return 1; + return compareCodePoints(a, b); +} + +function buildChildrenView( + scopePath: string, + entries: Dict, + eventIds: string[], + checkpointIds: string[], +): Dict { + const items = Object.keys(normalizeMapping(entries)) + .sort(compareEntryIds) + .filter((id) => isDict(normalizeMapping(entries)[id])) + .map((id) => normalizeEntrySummary(id, normalizeMapping(entries)[id] as Dict)); + const timestamps = items.map((item) => item["modified_ts"] as number).filter((ts) => ts > 0); + return { + view_id: `children_of:${scopePath}`, + view_type: "children_of", + scope_path: scopePath, + source_checkpoint_ids: checkpointIds, + source_event_ids: eventIds, + data: { children: items, total_children: items.length }, + modified_ts: Math.max(1, timestamps.length ? Math.max(...timestamps) : 0), + }; +} + +function buildCountsView( + scopePath: string, + entries: Dict, + eventIds: string[], + checkpointIds: string[], +): Dict { + const counts: Record = {}; + let archivedCount = 0; + const timestamps: number[] = []; + for (const entry of Object.values(normalizeMapping(entries))) { + if (!isDict(entry)) continue; + const kind = normalizeString(entry["kind"] ?? entry["type"] ?? "password"); + if (kind) counts[kind] = (counts[kind] ?? 0) + 1; + if (Boolean(entry["archived"] ?? entry["blacklisted"] ?? false)) archivedCount++; + timestamps.push(safeInt(entry["modified_ts"] ?? entry["updated"] ?? 0)); + } + const sorted: Dict = {}; + for (const key of Object.keys(counts).sort(compareCodePoints)) sorted[key] = counts[key]!; + return { + view_id: `counts_by_kind:${scopePath}`, + view_type: "counts_by_kind", + scope_path: scopePath, + source_checkpoint_ids: checkpointIds, + source_event_ids: eventIds, + data: { + counts: sorted, + archived_count: archivedCount, + total_entries: Object.values(counts).reduce((a, b) => a + b, 0), + }, + modified_ts: Math.max(1, timestamps.length ? Math.max(...timestamps) : 0), + }; +} + +function buildRecentActivityView( + scopePath: string, + events: Dict[], + checkpointIds: string[], +): Dict { + const ordered = [...events].sort((a, b) => -compareEvents(a, b)).slice( + 0, + INDEX0_RECENT_ACTIVITY_LIMIT, + ); + const items = ordered.map((event) => ({ + event_id: normalizeString(event["event_id"] ?? ""), + event_type: normalizeString(event["event_type"] ?? ""), + subject_id: normalizeString(event["subject_id"] ?? ""), + subject_kind: normalizeString(event["subject_kind"] ?? ""), + modified_ts: safeInt(event["modified_ts"] ?? 0), + summary: normalizeString(event["summary"] ?? ""), + })); + const timestamps = items.map((item) => item.modified_ts).filter((ts) => ts > 0); + return { + view_id: `recent_activity:${scopePath}`, + view_type: "recent_activity", + scope_path: scopePath, + source_checkpoint_ids: checkpointIds, + source_event_ids: items.map((item) => item.event_id).filter((id) => id), + data: { items, total_items: items.length }, + modified_ts: Math.max(1, timestamps.length ? Math.max(...timestamps) : 0), + }; +} + +/** Rebuild every canonical view from the entries and the event stream. */ +export function rebuildCanonicalViewsPayload( + payload: unknown, + options: { fingerprintDir?: string | null } = {}, +): Dict { + const out = ensureIndex0Payload(payload); + const index0 = (out["_system"] as Dict)["index0"] as unknown as Index0; + const entries = normalizeMapping(out["entries"]); + const events = Object.values(index0.events) + .map((event) => normalizeIndex0Event(event)) + .filter((event): event is Dict => event !== null); + const checkpoints = normalizeMapping(index0.checkpoints); + + const scopePaths = new Set(); + for (const event of events) { + const scope = normalizeString(event["scope_path"] ?? ""); + if (scope) scopePaths.add(scope); + } + if (options.fingerprintDir !== undefined && options.fingerprintDir !== null) { + scopePaths.add(deriveIndex0Context(options.fingerprintDir).scope_path); + } + + const views: Record = {}; + for (const scopePath of [...scopePaths].filter((s) => s).sort(compareCodePoints)) { + const scopeEvents = events.filter( + (event) => normalizeString(event["scope_path"] ?? "") === scopePath, + ); + const checkpointIds = Object.entries(checkpoints) + .filter( + ([, checkpoint]) => + isDict(checkpoint) && + scopeEvents.some( + (event) => + normalizeString(event["writer_id"] ?? "") === + normalizeString((checkpoint as Dict)["writer_id"] ?? ""), + ), + ) + .map(([id]) => id) + .sort(compareCodePoints); + const sourceEventIds = [ + ...new Set( + scopeEvents + .map((event) => normalizeString(event["event_id"] ?? "")) + .filter((id) => id), + ), + ].sort(compareCodePoints); + + for (const rawView of [ + buildChildrenView(scopePath, entries, sourceEventIds, checkpointIds), + buildCountsView(scopePath, entries, sourceEventIds, checkpointIds), + buildRecentActivityView(scopePath, scopeEvents, checkpointIds), + ]) { + rawView["view_hash"] = computeViewHash(rawView); + const normalized = normalizeCanonicalView(rawView, { + viewManifest: { local_only_view_types: [...LOCAL_ONLY_VIEW_TYPES].sort(compareCodePoints) }, + }); + if (normalized) views[String(normalized["view_id"])] = normalized; + } + } + + index0.view_manifest = normalizeViewManifest({ + version: 1, + canonical_view_types: [...INDEX0_CANONICAL_VIEW_TYPES], + local_only_view_types: [...LOCAL_ONLY_VIEW_TYPES].sort(compareCodePoints), + builder_versions: Object.fromEntries( + INDEX0_CANONICAL_VIEW_TYPES.map((viewType) => [viewType, 1]), + ), + }); + const sortedViews: Record = {}; + for (const key of Object.keys(views).sort(compareCodePoints)) sortedViews[key] = views[key]!; + index0.canonical_views = sortedViews; + index0.stats = recomputeIndex0Stats(index0 as unknown as Dict); + (out["_system"] as Dict)["index0"] = index0; + return out; +} + +export function compactIndex0Payload( + payload: unknown, + options: { maxCheckpointsPerWriter?: number; fingerprintDir?: string | null } = {}, +): Dict { + const out = ensureIndex0Payload(payload); + (out["_system"] as Dict)["index0"] = compactIndex0((out["_system"] as Dict)["index0"], { + ...(options.maxCheckpointsPerWriter !== undefined && { + maxCheckpointsPerWriter: options.maxCheckpointsPerWriter, + }), + }); + return rebuildCanonicalViewsPayload(out, { + ...(options.fingerprintDir !== undefined && { fingerprintDir: options.fingerprintDir }), + }); +} + +/** The index0 summary that goes into a sync manifest. */ +export function buildManifestIndex0Metadata( + payload: unknown, + options: { checkpointLimit?: number; fingerprintDir?: string | null } = {}, +): Dict { + const limit = options.checkpointLimit ?? INDEX0_MANIFEST_CHECKPOINT_LIMIT; + const compacted = compactIndex0Payload(payload, { + ...(options.fingerprintDir !== undefined && { fingerprintDir: options.fingerprintDir }), + }); + const index0 = (compacted["_system"] as Dict)["index0"] as unknown as Index0; + const checkpoints = Object.values(index0.checkpoints).filter(isDict); + const selected = [...checkpoints] + .sort((a, b) => { + const ats = safeInt(a["modified_ts"] ?? 0); + const bts = safeInt(b["modified_ts"] ?? 0); + if (ats !== bts) return bts - ats; + return compareCodePoints( + normalizeString(b["checkpoint_id"] ?? ""), + normalizeString(a["checkpoint_id"] ?? ""), + ); + }) + .slice(0, Math.max(0, limit)); + const checkpointIds = selected + .map((checkpoint) => normalizeString(checkpoint["checkpoint_id"] ?? "")) + .filter((id) => id); + + const checkpointHashes: Dict = {}; + for (const id of checkpointIds) { + const checkpoint = index0.checkpoints[id]; + if (checkpoint) checkpointHashes[id] = normalizeString(checkpoint["summary_hash"] ?? ""); + } + const streamHeads: Dict = {}; + for (const writerId of Object.keys(index0.heads).sort(compareCodePoints)) { + const head = index0.heads[writerId]!; + const hash = normalizeString(head["head_hash"] ?? ""); + if (hash) streamHeads[writerId] = hash; + } + return { + schema_version: INDEX0_SCHEMA_VERSION, + checkpoint_ids: checkpointIds, + checkpoint_hashes: checkpointHashes, + stream_heads: streamHeads, + }; +} + +export function listCanonicalViews(index0: unknown): Dict[] { + const normalized = normalizeIndex0(index0); + return Object.keys(normalized.canonical_views) + .sort(compareCodePoints) + .map((key) => normalized.canonical_views[key]!); +} + +export function getCanonicalView( + index0: unknown, + options: { viewType: string; scopePath: string }, +): Dict | null { + const normalized = normalizeIndex0(index0); + const viewId = `${normalizeString(options.viewType)}:${normalizeString(options.scopePath)}`; + const view = normalized.canonical_views[viewId]; + return isDict(view) ? { ...view } : null; +} + +// ------------------------------------------------------------------- merge + +/** + * Later timestamp wins; equal timestamps break on the record's own hash. + * + * The hash tie-break is what makes the merge deterministic across replicas — + * both sides compute the same winner without talking to each other. + */ +function preferHashedRecord(current: Dict, incoming: Dict, hashField: string): boolean { + const curTs = safeInt(current["modified_ts"] ?? 0); + const incTs = safeInt(incoming["modified_ts"] ?? 0); + if (incTs !== curTs) return incTs > curTs; + return ( + compareCodePoints( + normalizeString(incoming[hashField] ?? ""), + normalizeString(current[hashField] ?? ""), + ) > 0 + ); +} + +export function mergeSystemIndex0(current: unknown, incoming: unknown): Index0 { + const cur = normalizeIndex0(current); + const inc = normalizeIndex0(incoming); + + const events: Record = { ...cur.events }; + for (const [eventId, event] of Object.entries(inc.events)) { + const existing = events[eventId]; + if (!existing) { + events[eventId] = event; + continue; + } + if (canonicalJson(existing) === canonicalJson(event)) continue; + // Two different events claiming one id: pick by hash so both replicas + // reach the same answer. + if (compareCodePoints(canonicalHash(event), canonicalHash(existing)) > 0) { + events[eventId] = event; + } + } + + const checkpoints: Record = { ...cur.checkpoints }; + for (const [id, checkpoint] of Object.entries(inc.checkpoints)) { + const existing = checkpoints[id]; + if (!existing || preferHashedRecord(existing, checkpoint, "summary_hash")) { + checkpoints[id] = checkpoint; + } + } + + const union = (a: unknown, b: unknown): string[] => { + const out = new Set(); + for (const list of [a, b]) { + if (Array.isArray(list)) for (const item of list) out.add(String(item)); + } + return [...out].sort(compareCodePoints); + }; + const viewManifest = normalizeViewManifest({ + version: Math.max( + safeInt(cur.view_manifest["version"] ?? 1, 1), + safeInt(inc.view_manifest["version"] ?? 1, 1), + ), + canonical_view_types: union( + cur.view_manifest["canonical_view_types"], + inc.view_manifest["canonical_view_types"], + ), + local_only_view_types: union( + cur.view_manifest["local_only_view_types"], + inc.view_manifest["local_only_view_types"], + ), + builder_versions: { + ...normalizeMapping(cur.view_manifest["builder_versions"]), + ...normalizeMapping(inc.view_manifest["builder_versions"]), + }, + }); + + const views: Record = { ...cur.canonical_views }; + for (const [viewId, view] of Object.entries(inc.canonical_views)) { + const normalized = normalizeCanonicalView(view, { viewManifest }); + if (!normalized) continue; + const existing = views[viewId]; + if (!existing || preferHashedRecord(existing, normalized, "view_hash")) { + views[viewId] = normalized; + } + } + + const heads: Record = { ...cur.heads }; + for (const [writerId, head] of Object.entries(inc.heads)) { + const existing = heads[writerId]; + if (!existing || preferHashedRecord(existing, head, "head_hash")) { + heads[writerId] = head; + } + } + + const sortRecords = (source: Record): Record => { + const out: Record = {}; + for (const key of Object.keys(source).sort(compareCodePoints)) out[key] = source[key]!; + return out; + }; + + const merged: Index0 = { + schema_version: Math.max(cur.schema_version, inc.schema_version), + events: sortRecords(events), + checkpoints: sortRecords(checkpoints), + canonical_views: sortRecords(views), + view_manifest: viewManifest, + heads: sortRecords(heads), + stats: {}, + }; + merged.stats = recomputeIndex0Stats(merged as unknown as Dict); + return merged; +} diff --git a/js/packages/core/src/vault/index0Events.ts b/js/packages/core/src/vault/index0Events.ts new file mode 100644 index 00000000..277bee10 --- /dev/null +++ b/js/packages/core/src/vault/index0Events.ts @@ -0,0 +1,118 @@ +/** + * Emit index0 events for whatever a vault mutation actually changed. + * + * Python emits from each call site in EntryManager, naming the operation it + * just performed. This derives the events by comparing the index before and + * after instead, and is wired into the single mutation funnel every surface + * goes through (CLI, TUI, API). + * + * That is a deliberate difference, for one reason: a call-site emitter is + * something a new code path can forget, and a silently incomplete activity + * ledger is worse than an absent one — it reads as "nothing happened". A + * funnel-level differ cannot be forgotten, because there is nowhere else to + * write a change from. The event VOCABULARY matches Python's + * (entry_created / entry_updated / entry_deleted), so a stream from either + * implementation reads the same. + */ + +import { appendIndex0Event, type Dict } from "./index0.js"; + +function isDict(value: unknown): value is Dict { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function kindOf(entry: Dict): string { + return String(entry["kind"] ?? entry["type"] ?? "").trim() || "entry"; +} + +function tsOf(entry: Dict, fallback: number): number { + const raw = Number(entry["modified_ts"] ?? 0); + return Number.isFinite(raw) && raw > 0 ? Math.trunc(raw) : fallback; +} + +export interface EmitOptions { + /** Entries as they were before the mutation. */ + before: Record; + /** Entries as they are after it. */ + after: Record; + fingerprintDir: string; + /** Seconds since the epoch, for entries carrying no timestamp of their own. */ + now: number; +} + +/** + * Append one event per changed entry, returning the updated payload. + * + * Comparison is by canonical JSON of the whole entry, so a change to any + * field counts — including ones no specific call site would have thought to + * report. + */ +export function emitEntryEvents(payload: unknown, options: EmitOptions): Dict { + const { before, after, fingerprintDir, now } = options; + let out = payload as Dict; + + const ids = new Set([...Object.keys(before), ...Object.keys(after)]); + // Sorted so the emitted order is deterministic: two replicas performing the + // same batch must produce the same stream. + for (const id of [...ids].sort((a, b) => { + const an = Number(a); + const bn = Number(b); + if (Number.isInteger(an) && Number.isInteger(bn)) return an - bn; + return a < b ? -1 : a > b ? 1 : 0; + })) { + const oldEntry = before[id]; + const newEntry = after[id]; + const had = isDict(oldEntry); + const has = isDict(newEntry); + if (!had && !has) continue; + + if (has && !had) { + out = appendIndex0Event(out, { + eventType: "entry_created", + subjectType: "entry", + subjectId: id, + subjectKind: kindOf(newEntry), + modifiedTs: tsOf(newEntry, now), + fingerprintDir, + payloadRef: { entry_id: id }, + links: Array.isArray(newEntry["links"]) ? (newEntry["links"] as unknown[]) : [], + tags: Array.isArray(newEntry["tags"]) ? (newEntry["tags"] as unknown[]) : [], + summary: `Created ${kindOf(newEntry)} entry`, + source: "entry_management", + }); + continue; + } + + if (had && !has) { + out = appendIndex0Event(out, { + eventType: "entry_deleted", + subjectType: "entry", + subjectId: id, + subjectKind: kindOf(oldEntry), + modifiedTs: now, + fingerprintDir, + payloadRef: { entry_id: id }, + summary: `Deleted ${kindOf(oldEntry)} entry`, + source: "entry_management", + }); + continue; + } + + if (had && has && JSON.stringify(oldEntry) !== JSON.stringify(newEntry)) { + out = appendIndex0Event(out, { + eventType: "entry_updated", + subjectType: "entry", + subjectId: id, + subjectKind: kindOf(newEntry), + modifiedTs: tsOf(newEntry, now), + fingerprintDir, + payloadRef: { entry_id: id }, + links: Array.isArray(newEntry["links"]) ? (newEntry["links"] as unknown[]) : [], + tags: Array.isArray(newEntry["tags"]) ? (newEntry["tags"] as unknown[]) : [], + summary: `Updated ${kindOf(newEntry)} entry`, + source: "entry_management", + }); + } + } + return out; +} diff --git a/js/packages/core/test/index0.test.ts b/js/packages/core/test/index0.test.ts new file mode 100644 index 00000000..47053364 --- /dev/null +++ b/js/packages/core/test/index0.test.ts @@ -0,0 +1,242 @@ +/** + * index0 / atlas. + * + * This is derived state whose whole contract is hash parity: event ids, + * integrity hashes, checkpoint summaries and view hashes are SHA-256 over + * Python's canonical JSON, and the merge picks winners by comparing those + * hashes. One byte of disagreement in the encoding means the two + * implementations permanently disagree about which record wins. + * + * So the load-bearing tests compare whole structures against fixtures the + * Python implementation produced, via canonicalJson rather than toEqual — + * an assertion that passes only if the bytes that get hashed are identical. + */ + +import { describe, expect, it } from "vitest"; +import { + appendIndex0Event, + compactIndex0Payload, + buildManifestIndex0Metadata, + mergeSystemIndex0, + deriveIndex0Context, + listCanonicalViews, + getCanonicalView, + normalizeIndex0, + normalizeIndex0Event, + rebuildCanonicalViewsPayload, + emitEntryEvents, + canonicalJson, + LOCAL_ONLY_VIEW_TYPES, +} from "@seedpass/core"; +import { index0Fixture as fx } from "@seedpass/test-vectors"; + +const FP = "/home/user/.seedpass/C557EEC878DFD852"; +const MANAGED = "/home/user/.seedpass/C557EEC878DFD852/accounts/AABBCCDDEEFF0011"; + +/** Rebuild the fixture's event stream, event for event. */ +function buildAppended(): Record { + let payload: unknown = { schema_version: 4, entries: fx.entries }; + const specs = [ + [1700000100, "entry.created", "0", "password"], + [1700000200, "entry.created", "1", "totp"], + [1700086400, "entry.updated", "0", "password"], + [1700086500, "entry.archived", "1", "totp"], + ] as const; + specs.forEach(([ts, eventType, subjectId, subjectKind], i) => { + payload = appendIndex0Event(payload, { + eventType, + subjectType: "entry", + subjectId, + subjectKind, + modifiedTs: ts, + fingerprintDir: FP, + tags: ["auto"], + summary: `event ${i} — ünï`, + }); + }); + return appendIndex0Event(payload, { + eventType: "entry.created", + subjectType: "entry", + subjectId: "7", + subjectKind: "seed", + modifiedTs: 1700090000, + fingerprintDir: MANAGED, + }); +} + +describe("byte-for-byte parity with Python", () => { + it("derives the same writer context, including managed accounts", () => { + expect(deriveIndex0Context(FP)).toEqual(fx.context_root); + // A managed account's scope names both the root and the child, so views + // from a parent and its child cannot collide. + expect(deriveIndex0Context(MANAGED)).toEqual(fx.context_managed); + }); + + it("produces an identical event stream, hashes and heads", () => { + // Every event id is a hash of the event, and each chains onto the + // writer's previous head — so this passing means the canonical encoding, + // the field set and the chaining all match. + expect(canonicalJson(buildAppended())).toBe(canonicalJson(fx.appended)); + }); + + it("compacts to identical checkpoints and views", () => { + const compacted = compactIndex0Payload(buildAppended(), { fingerprintDir: FP }); + expect(canonicalJson(compacted)).toBe(canonicalJson(fx.compacted)); + }); + + it("produces identical manifest metadata", () => { + expect( + canonicalJson(buildManifestIndex0Metadata(buildAppended(), { fingerprintDir: FP })), + ).toBe(canonicalJson(fx.manifest_meta)); + }); + + it("merges two replicas to an identical result", () => { + const compacted = compactIndex0Payload(buildAppended(), { fingerprintDir: FP }); + const mine = (compacted as Record)["_system"].index0; + const theirs = (fx.other as Record)["_system"].index0; + expect(canonicalJson(mergeSystemIndex0(mine, theirs))).toBe(canonicalJson(fx.merged)); + }); + + it("lists and fetches views identically", () => { + const index0 = (compactIndex0Payload(buildAppended(), { fingerprintDir: FP }) as Record< + string, + any + >)["_system"].index0; + expect(canonicalJson(listCanonicalViews(index0))).toBe(canonicalJson(fx.views)); + expect( + canonicalJson( + getCanonicalView(index0, { + viewType: "counts_by_kind", + scopePath: fx.context_root["scope_path"]!, + }), + ), + ).toBe(canonicalJson(fx.one_view)); + }); +}); + +describe("normalization refuses what it cannot place", () => { + it("drops events with no usable timestamp", () => { + // A timestamp is what orders the stream; without one the event cannot be + // placed, and guessing would corrupt the ordering silently. + for (const bad of [0, -1, "", "abc", null]) { + expect(normalizeIndex0Event({ event_type: "x", subject_type: "entry", subject_id: "1", writer_id: "w", modified_ts: bad })).toBeNull(); + } + }); + + it("drops events missing a required field", () => { + const base = { + event_type: "entry_created", + subject_type: "entry", + subject_id: "1", + writer_id: "writer:profile:AAAA", + modified_ts: 1700000000, + }; + expect(normalizeIndex0Event(base)).not.toBeNull(); + for (const field of ["event_type", "subject_type", "subject_id", "writer_id"]) { + expect(normalizeIndex0Event({ ...base, [field]: "" })).toBeNull(); + } + }); + + it("preserves a stored integrity hash even when it disagrees", () => { + // This normalizes; it does not verify. Rewriting a mismatched hash would + // hide exactly the tampering the hash exists to reveal. + const event = normalizeIndex0Event({ + event_type: "entry_created", + subject_type: "entry", + subject_id: "1", + writer_id: "w", + modified_ts: 1700000000, + integrity_hash: "deadbeef", + })!; + expect(event["integrity_hash"]).toBe("deadbeef"); + }); + + it("never admits a local-only view into shared state", () => { + // These are derived from data that does not leave the machine, so + // syncing them would publish inferences about it. + for (const viewType of LOCAL_ONLY_VIEW_TYPES) { + const normalized = normalizeIndex0({ + canonical_views: { + "x:y": { view_id: "x:y", view_type: viewType, modified_ts: 1700000000 }, + }, + }); + expect(normalized.canonical_views).toEqual({}); + } + }); + + it("survives a malformed entry id rather than failing to build views", () => { + // A vault that will not open is indistinguishable from one that is lost, + // so a hand-edited id degrades the ordering instead of throwing. + const rebuilt = rebuildCanonicalViewsPayload( + { entries: { "2": { kind: "password", label: "b" }, "not-a-number": { kind: "password", label: "a" } } }, + { fingerprintDir: FP }, + ); + const view = getCanonicalView((rebuilt as any)._system.index0, { + viewType: "children_of", + scopePath: deriveIndex0Context(FP).scope_path, + })!; + const ids = ((view["data"] as any).children as any[]).map((c) => c.entry_id); + // Numeric ids first, non-numeric after — the fixture proves Python agrees. + expect(ids).toEqual(["2", "not-a-number"]); + }); + + it("tolerates junk in place of the whole block", () => { + for (const junk of [null, "string", 42, []]) { + const normalized = normalizeIndex0(junk); + expect(normalized.events).toEqual({}); + expect(normalized.stats["event_count"]).toBe(0); + } + }); +}); + +describe("the differ records what a mutation changed", () => { + const base = { schema_version: 4, entries: {} as Record }; + + it("emits create, update and delete with Python's vocabulary", () => { + const before = { "0": { kind: "password", label: "a", modified_ts: 1700000000 } }; + const after = { + "0": { kind: "password", label: "renamed", modified_ts: 1700000500 }, + "1": { kind: "totp", label: "new", modified_ts: 1700000600 }, + }; + const out = emitEntryEvents( + { ...base, entries: after, _system: { index0: { events: {} } } }, + { before, after, fingerprintDir: FP, now: 1700000700 }, + ); + const events = Object.values((out as any)._system.index0.events) as any[]; + const byType = events.map((e) => e.event_type).sort(); + expect(byType).toEqual(["entry_created", "entry_updated"]); + + const deleted = emitEntryEvents( + { ...base, entries: {}, _system: { index0: { events: {} } } }, + { before, after: {}, fingerprintDir: FP, now: 1700000700 }, + ); + const deletedEvents = Object.values((deleted as any)._system.index0.events) as any[]; + expect(deletedEvents[0].event_type).toBe("entry_deleted"); + expect(deletedEvents[0].subject_id).toBe("0"); + }); + + it("emits nothing when nothing changed", () => { + const entries = { "0": { kind: "password", label: "a", modified_ts: 1700000000 } }; + const out = emitEntryEvents( + { ...base, entries, _system: { index0: { events: {} } } }, + { before: entries, after: entries, fingerprintDir: FP, now: 1700000700 }, + ); + expect(Object.keys((out as any)._system.index0.events)).toHaveLength(0); + }); + + it("emits in a deterministic order, so two replicas agree", () => { + const after = { + "10": { kind: "password", label: "j", modified_ts: 1700000010 }, + "2": { kind: "password", label: "b", modified_ts: 1700000002 }, + "1": { kind: "password", label: "a", modified_ts: 1700000001 }, + }; + const run = () => + canonicalJson( + emitEntryEvents( + { ...base, entries: after, _system: { index0: { events: {} } } }, + { before: {}, after, fingerprintDir: FP, now: 1700000700 }, + ), + ); + expect(run()).toBe(run()); + }); +}); diff --git a/js/packages/test-vectors/fixtures/high_risk.json b/js/packages/test-vectors/fixtures/high_risk.json index 3eec16e4..222d9e14 100644 --- a/js/packages/test-vectors/fixtures/high_risk.json +++ b/js/packages/test-vectors/fixtures/high_risk.json @@ -25,11 +25,11 @@ "kdf": "pbkdf2-sha256", "salt_b64": "AAECAwQFBgcICQoLDA0ODw==", "version": 1, - "wrapped_partition_key": "gAAAAABqhbsiuZu-EfSBrvoDDpPRJXSPP30W1kGfWZz-WMbSHKjqJn_qHWYfiwi-4xhlczaRZKTUwANB-UIAo5XkAi7MbNwBlT_ElZaSQkskX85zbTuC8sbioHEUT1kASzV6rIbbta0e" + "wrapped_partition_key": "gAAAAABlU_EAAAECAwQFBgcICQoLDA0OD5IWjg3AYnG2ZBi9dQctSsCrYOHkdaqFp5aGi3XpMl4t2OXcBZi1glMl1L2NHKqL4XsvRw--CptMCQgvuHnFA3iNF_1d8dib4NxUmgQ0NRnd" }, "factor": "second-factor-passphrase", "fixture_version": 1, - "partition_file_b64": "Z0FBQUFBQnFoYnNpU2FoTjlTZDF1MUNwV1JwLVRqNTRiWlplWXRRTXZhVzdqa2FoYUVMUl84NDVmdzdoNjJucFgzUzFWbFh5aGRuOW9UcWNfY3lLMTZpSTYySGo4LXVJM0tOYW0tVmc0bHhtOGVITkd4MWw4VEJOeGk3eF9NZWloQmoxWUJkaTg3RUlZdHZnWkVobE00cHQ0NzVucnRTNlJYR0RPMnozYV9pTVl0SVRqcjE3eUdRUkVwVzBuMHlCcGJxZ2VZbHRaallmclpIdlNkTnpwczRIdF9BeWVITHQtTER3S0tPMU9ZZU41VkkwWlRKdFg5eEw3SFIycm44MnNKTzNHVUFYVTNBWFRjbEx3Ri1vLVYxY2Y3cmQxUmstTHBSX0tvekZFZUxfdmZsV05aMGJ5WXgxMHFvbFlXZ1VYbTVyUWJ4ZG9BUXdFRklLVS1YZUxOTW1FekVVcGpXUGlicFktbm52NmR3Nlk0aW9RdUFGZmZwRlpnN091dDh5NUlqOXFwVEpGeGZSdEROVUtfTzhiUVdFamZqSHo5RGF1TmV0YWkzNkwyS3FnX0lJVTducEdzZ0xJbEVvRVFfX09Va1Byb0lWaTdMNlFnSDRmYUNpb2UxVGlsU19lQWMzRS1oRlZQSHpOM1ptRG05dU16QjdUQVZycGxWcUdlNWEtRjBoNkNncUU4SVo=", + "partition_file_b64": "Z0FBQUFBQmxVX0VBQUFFQ0F3UUZCZ2NJQ1FvTERBME9EOWZDeDVfMXBmbkNTRGc4cEk0SWk1b1FxQzNJaFNScTllWEsyT2hTWkQwUWhCX3JiUkE5RXFzTWYzS1pXT2F4OE5kTDJfZW9FNHRiVUNmZGJ1Nl9JT2ZFbHUxV243SWdqbnlDUjZac1J6ZFpVaDVITTlZb0RhVDQ3MDB5VEJON2ZUcVlRZXNPSnFsMWx2Si11eGw1VTVBLXF1MHJwVWx3cWlmWldvQnBDeDZ5QnY2RjcwbWdfT3lyNFVCWm1mYjk4SW41U19nck50dWFmV1lGSkFCdFA5eXFubU9MUHFfZ0RZNDVOVGJTX0F3ODRSX1JsRGJ0bXpuSElvNHAweWpQZC14SmlUakNzSTY0ejl5cElsTVRFWlRhemhGVEl4QTJEdlp6akdaR0VjS3R0dW9jb2RQa2FtQ1VGNEVCalhIdUh5a3hsdXpCcVZ2YXlCb2JQc2l2VlQxSGlfaVlyVjVsRjhsZVFBVnBubDZPdFNDZF9HbDdnZG1RSFhuZzdxRVVPVHprYXBKaC16NlhXTFRLZjhEQkZqZ3BpeEh5bnBWandWdDI2U29ZeXdlNVA5TVU5N3J5dk8yOHNNV1hqZnhIdktDa0hCRnJpMFlPV2FNTE9EbW8wZkpXLUJKZC05WGxKRnhHV0ZSSTR0WjA=", "partition_key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", "tag": "905f28def18eaac05ae6f12b2c3452744afaf626da1343d57b395b544e0519b6" } diff --git a/js/packages/test-vectors/fixtures/index0.json b/js/packages/test-vectors/fixtures/index0.json new file mode 100644 index 00000000..cfce9340 --- /dev/null +++ b/js/packages/test-vectors/fixtures/index0.json @@ -0,0 +1,1708 @@ +{ + "appended": { + "_system": { + "index0": { + "canonical_views": {}, + "checkpoints": {}, + "events": { + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd": { + "actor_id": "AABBCCDDEEFF0011", + "actor_type": "user", + "classification": "internal", + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "integrity_hash": "06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "links": [], + "modified_ts": 1700090000, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "subject_id": "7", + "subject_kind": "seed", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "integrity_hash": "a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "links": [], + "modified_ts": 1700000200, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "7ce12c9b183701d408c08aa95c8a47bab41e9b9bdefe20cd158359b0eff7adfb", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 1 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "integrity_hash": "b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "links": [], + "modified_ts": 1700086400, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 2 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "integrity_hash": "e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "links": [], + "modified_ts": 1700086500, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "5490e096426699e9c417b4f470d4b5ca5175f7805d9e3bac850b736c8dc90186", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 3 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "integrity_hash": "f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "links": [], + "modified_ts": 1700000100, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 0 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "heads": { + "writer:profile:AABBCCDDEEFF0011": { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000 + }, + "writer:profile:C557EEC878DFD852": { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "head_hash": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464", + "modified_ts": 1700086500 + } + }, + "schema_version": 1, + "stats": { + "checkpoint_count": 0, + "event_count": 5, + "last_compaction_ts": 0, + "last_validation_ts": 1700090000, + "writer_count": 2 + }, + "view_manifest": { + "builder_versions": {}, + "canonical_view_types": [], + "local_only_view_types": [], + "version": 1 + } + } + }, + "entries": { + "0": { + "kind": "password", + "label": "bank.example", + "links": [ + { + "note": "recovery", + "relation": "depends_on", + "target_id": "2" + } + ], + "modified_ts": 1700000100, + "tags": [ + "money", + "daily" + ] + }, + "1": { + "archived": true, + "kind": "totp", + "label": "email-2fa", + "modified_ts": 1700000200 + }, + "10": { + "kind": "document", + "label": "notes", + "modified_ts": 1700000400 + }, + "2": { + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "modified_ts": 1700000300, + "tags": [ + "ops", + "\u00fcn\u00ef" + ] + }, + "not-a-number": { + "kind": "password", + "label": "weird id", + "modified_ts": 1700000500 + } + }, + "schema_version": 4 + }, + "compacted": { + "_system": { + "index0": { + "canonical_views": { + "children_of:seed/C557EEC878DFD852": { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "3bf8aa8e904e217efa279005d1d4c7d5e7dc45f922661733655ca3df9b58a59b", + "view_id": "children_of:seed/C557EEC878DFD852", + "view_type": "children_of" + }, + "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "8dfdf962988e8d9ba0911ffe32325c75c092d2dd7e1bd09a7c9fbdd1ae6dbee7", + "view_id": "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "children_of" + }, + "counts_by_kind:seed/C557EEC878DFD852": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "582d4144faeee64620eac8307595876fc66dc1e2e20bfb15c375af04866b6523", + "view_id": "counts_by_kind:seed/C557EEC878DFD852", + "view_type": "counts_by_kind" + }, + "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "17fd876bd4e7b408fe7aa08daf17ff53d3023ea25d21da81996aea407ac84436", + "view_id": "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "counts_by_kind" + }, + "recent_activity:seed/C557EEC878DFD852": { + "data": { + "items": [ + { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "modified_ts": 1700086500, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 3 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "modified_ts": 1700086400, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 2 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "modified_ts": 1700000200, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 1 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "modified_ts": 1700000100, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 0 \u2014 \u00fcn\u00ef" + } + ], + "total_items": 4 + }, + "modified_ts": 1700086500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "2789d66e1f16c659f9edde0c7c3cee89e579640a022ad344a3d50cfc0dfa81e3", + "view_id": "recent_activity:seed/C557EEC878DFD852", + "view_type": "recent_activity" + }, + "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "items": [ + { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "modified_ts": 1700090000, + "subject_id": "7", + "subject_kind": "seed", + "summary": "" + } + ], + "total_items": 1 + }, + "modified_ts": 1700090000, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "58b037aa5a5ef62a6dea6f648603c272b03db3c35e62715865a45f4539043b3a", + "view_id": "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "recent_activity" + } + }, + "checkpoints": { + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852": { + "checkpoint_id": "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "event_count": 2, + "head_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "modified_ts": 1700000200, + "rollup": { + "events_by_type": { + "entry.created": 2 + }, + "subjects": [ + "0", + "1" + ], + "subjects_by_kind": { + "password": 1, + "totp": 1 + } + }, + "summary_hash": "b555832825c7937caa301eca7b8741f0d68e5f9a494803d114e0761c45de17a4", + "window_end_ts": 1700000200, + "window_key": "2023-11-14", + "window_start_ts": 1700000100, + "window_type": "day", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011": { + "checkpoint_id": "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011", + "event_count": 1, + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000, + "rollup": { + "events_by_type": { + "entry.created": 1 + }, + "subjects": [ + "7" + ], + "subjects_by_kind": { + "seed": 1 + } + }, + "summary_hash": "1fe90410bc94f4f1cc09c11fa39f6f1d76297b26bda81624fd0e68831f72b6fc", + "window_end_ts": 1700090000, + "window_key": "2023-11-15", + "window_start_ts": 1700090000, + "window_type": "day", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852": { + "checkpoint_id": "cp:day:2023-11-15:writer:profile:C557EEC878DFD852", + "event_count": 2, + "head_hash": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464", + "modified_ts": 1700086500, + "rollup": { + "events_by_type": { + "entry.archived": 1, + "entry.updated": 1 + }, + "subjects": [ + "0", + "1" + ], + "subjects_by_kind": { + "password": 1, + "totp": 1 + } + }, + "summary_hash": "f6808d98e759ed5ed4e2ef26755089c69d9113ddc466e5017c656eccf741b5c0", + "window_end_ts": 1700086500, + "window_key": "2023-11-15", + "window_start_ts": 1700086400, + "window_type": "day", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "events": { + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd": { + "actor_id": "AABBCCDDEEFF0011", + "actor_type": "user", + "classification": "internal", + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "integrity_hash": "06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "links": [], + "modified_ts": 1700090000, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "subject_id": "7", + "subject_kind": "seed", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "integrity_hash": "a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "links": [], + "modified_ts": 1700000200, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "7ce12c9b183701d408c08aa95c8a47bab41e9b9bdefe20cd158359b0eff7adfb", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 1 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "integrity_hash": "b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "links": [], + "modified_ts": 1700086400, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 2 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "integrity_hash": "e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "links": [], + "modified_ts": 1700086500, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "5490e096426699e9c417b4f470d4b5ca5175f7805d9e3bac850b736c8dc90186", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 3 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "integrity_hash": "f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "links": [], + "modified_ts": 1700000100, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 0 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "heads": { + "writer:profile:AABBCCDDEEFF0011": { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000 + }, + "writer:profile:C557EEC878DFD852": { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "head_hash": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464", + "modified_ts": 1700086500 + } + }, + "schema_version": 1, + "stats": { + "checkpoint_count": 3, + "event_count": 5, + "last_compaction_ts": 1700090000, + "last_validation_ts": 1700090000, + "writer_count": 2 + }, + "view_manifest": { + "builder_versions": { + "children_of": 1, + "counts_by_kind": 1, + "recent_activity": 1 + }, + "canonical_view_types": [ + "children_of", + "counts_by_kind", + "recent_activity" + ], + "local_only_view_types": [ + "conversation_index", + "hot_nodes", + "semantic_neighbors" + ], + "version": 1 + } + } + }, + "entries": { + "0": { + "kind": "password", + "label": "bank.example", + "links": [ + { + "note": "recovery", + "relation": "depends_on", + "target_id": "2" + } + ], + "modified_ts": 1700000100, + "tags": [ + "money", + "daily" + ] + }, + "1": { + "archived": true, + "kind": "totp", + "label": "email-2fa", + "modified_ts": 1700000200 + }, + "10": { + "kind": "document", + "label": "notes", + "modified_ts": 1700000400 + }, + "2": { + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "modified_ts": 1700000300, + "tags": [ + "ops", + "\u00fcn\u00ef" + ] + }, + "not-a-number": { + "kind": "password", + "label": "weird id", + "modified_ts": 1700000500 + } + }, + "schema_version": 4 + }, + "context_managed": { + "actor_id": "AABBCCDDEEFF0011", + "actor_type": "user", + "current_fingerprint": "AABBCCDDEEFF0011", + "root_fingerprint": "C557EEC878DFD852", + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "context_root": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "current_fingerprint": "C557EEC878DFD852", + "root_fingerprint": "C557EEC878DFD852", + "scope_path": "seed/C557EEC878DFD852", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "description": "index0/atlas events, checkpoints, views and merge", + "entries": { + "0": { + "kind": "password", + "label": "bank.example", + "links": [ + { + "note": "recovery", + "relation": "depends_on", + "target_id": "2" + } + ], + "modified_ts": 1700000100, + "tags": [ + "money", + "daily" + ] + }, + "1": { + "archived": true, + "kind": "totp", + "label": "email-2fa", + "modified_ts": 1700000200 + }, + "10": { + "kind": "document", + "label": "notes", + "modified_ts": 1700000400 + }, + "2": { + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "modified_ts": 1700000300, + "tags": [ + "ops", + "\u00fcn\u00ef" + ] + }, + "not-a-number": { + "kind": "password", + "label": "weird id", + "modified_ts": 1700000500 + } + }, + "fixture_version": 1, + "manifest_meta": { + "checkpoint_hashes": { + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852": "b555832825c7937caa301eca7b8741f0d68e5f9a494803d114e0761c45de17a4", + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011": "1fe90410bc94f4f1cc09c11fa39f6f1d76297b26bda81624fd0e68831f72b6fc", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852": "f6808d98e759ed5ed4e2ef26755089c69d9113ddc466e5017c656eccf741b5c0" + }, + "checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852" + ], + "schema_version": 1, + "stream_heads": { + "writer:profile:AABBCCDDEEFF0011": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "writer:profile:C557EEC878DFD852": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464" + } + }, + "merged": { + "canonical_views": { + "children_of:seed/C557EEC878DFD852": { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "3bf8aa8e904e217efa279005d1d4c7d5e7dc45f922661733655ca3df9b58a59b", + "view_id": "children_of:seed/C557EEC878DFD852", + "view_type": "children_of" + }, + "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "8dfdf962988e8d9ba0911ffe32325c75c092d2dd7e1bd09a7c9fbdd1ae6dbee7", + "view_id": "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "children_of" + }, + "counts_by_kind:seed/C557EEC878DFD852": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "582d4144faeee64620eac8307595876fc66dc1e2e20bfb15c375af04866b6523", + "view_id": "counts_by_kind:seed/C557EEC878DFD852", + "view_type": "counts_by_kind" + }, + "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "17fd876bd4e7b408fe7aa08daf17ff53d3023ea25d21da81996aea407ac84436", + "view_id": "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "counts_by_kind" + }, + "recent_activity:seed/C557EEC878DFD852": { + "data": { + "items": [ + { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "modified_ts": 1700086500, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 3 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "modified_ts": 1700086400, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 2 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "modified_ts": 1700000200, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 1 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "modified_ts": 1700000100, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 0 \u2014 \u00fcn\u00ef" + } + ], + "total_items": 4 + }, + "modified_ts": 1700086500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "2789d66e1f16c659f9edde0c7c3cee89e579640a022ad344a3d50cfc0dfa81e3", + "view_id": "recent_activity:seed/C557EEC878DFD852", + "view_type": "recent_activity" + }, + "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "items": [ + { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "modified_ts": 1700090000, + "subject_id": "7", + "subject_kind": "seed", + "summary": "" + } + ], + "total_items": 1 + }, + "modified_ts": 1700090000, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "58b037aa5a5ef62a6dea6f648603c272b03db3c35e62715865a45f4539043b3a", + "view_id": "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "recent_activity" + } + }, + "checkpoints": { + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852": { + "checkpoint_id": "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "event_count": 2, + "head_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "modified_ts": 1700000200, + "rollup": { + "events_by_type": { + "entry.created": 2 + }, + "subjects": [ + "0", + "1" + ], + "subjects_by_kind": { + "password": 1, + "totp": 1 + } + }, + "summary_hash": "b555832825c7937caa301eca7b8741f0d68e5f9a494803d114e0761c45de17a4", + "window_end_ts": 1700000200, + "window_key": "2023-11-14", + "window_start_ts": 1700000100, + "window_type": "day", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011": { + "checkpoint_id": "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011", + "event_count": 1, + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000, + "rollup": { + "events_by_type": { + "entry.created": 1 + }, + "subjects": [ + "7" + ], + "subjects_by_kind": { + "seed": 1 + } + }, + "summary_hash": "1fe90410bc94f4f1cc09c11fa39f6f1d76297b26bda81624fd0e68831f72b6fc", + "window_end_ts": 1700090000, + "window_key": "2023-11-15", + "window_start_ts": 1700090000, + "window_type": "day", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852": { + "checkpoint_id": "cp:day:2023-11-15:writer:profile:C557EEC878DFD852", + "event_count": 2, + "head_hash": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464", + "modified_ts": 1700086500, + "rollup": { + "events_by_type": { + "entry.archived": 1, + "entry.updated": 1 + }, + "subjects": [ + "0", + "1" + ], + "subjects_by_kind": { + "password": 1, + "totp": 1 + } + }, + "summary_hash": "f6808d98e759ed5ed4e2ef26755089c69d9113ddc466e5017c656eccf741b5c0", + "window_end_ts": 1700086500, + "window_key": "2023-11-15", + "window_start_ts": 1700086400, + "window_type": "day", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "events": { + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd": { + "actor_id": "AABBCCDDEEFF0011", + "actor_type": "user", + "classification": "internal", + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "integrity_hash": "06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "links": [], + "modified_ts": 1700090000, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "subject_id": "7", + "subject_kind": "seed", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "integrity_hash": "a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "links": [], + "modified_ts": 1700000200, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "7ce12c9b183701d408c08aa95c8a47bab41e9b9bdefe20cd158359b0eff7adfb", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 1 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "integrity_hash": "b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "links": [], + "modified_ts": 1700086400, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 2 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "integrity_hash": "e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "links": [], + "modified_ts": 1700086500, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "5490e096426699e9c417b4f470d4b5ca5175f7805d9e3bac850b736c8dc90186", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 3 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "integrity_hash": "f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "links": [], + "modified_ts": 1700000100, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 0 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "event_type": "entry.created", + "integrity_hash": "f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "links": [], + "modified_ts": 1700099999, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "99", + "subject_kind": "password", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "heads": { + "writer:profile:AABBCCDDEEFF0011": { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000 + }, + "writer:profile:C557EEC878DFD852": { + "event_id": "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "head_hash": "1ebcaceb046d155dcadcb6ad78c6f1021fbe084d9fafe29d5de96457ebfe1ef4", + "modified_ts": 1700099999 + } + }, + "schema_version": 1, + "stats": { + "checkpoint_count": 3, + "event_count": 6, + "last_compaction_ts": 1700090000, + "last_validation_ts": 1700099999, + "writer_count": 2 + }, + "view_manifest": { + "builder_versions": { + "children_of": 1, + "counts_by_kind": 1, + "recent_activity": 1 + }, + "canonical_view_types": [ + "children_of", + "counts_by_kind", + "recent_activity" + ], + "local_only_view_types": [ + "conversation_index", + "hot_nodes", + "semantic_neighbors" + ], + "version": 1 + } + }, + "one_view": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "582d4144faeee64620eac8307595876fc66dc1e2e20bfb15c375af04866b6523", + "view_id": "counts_by_kind:seed/C557EEC878DFD852", + "view_type": "counts_by_kind" + }, + "other": { + "_system": { + "index0": { + "canonical_views": {}, + "checkpoints": {}, + "events": { + "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "event_type": "entry.created", + "integrity_hash": "f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "links": [], + "modified_ts": 1700099999, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "99", + "subject_kind": "password", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "heads": { + "writer:profile:C557EEC878DFD852": { + "event_id": "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "head_hash": "1ebcaceb046d155dcadcb6ad78c6f1021fbe084d9fafe29d5de96457ebfe1ef4", + "modified_ts": 1700099999 + } + }, + "schema_version": 1, + "stats": { + "checkpoint_count": 0, + "event_count": 1, + "last_compaction_ts": 0, + "last_validation_ts": 1700099999, + "writer_count": 1 + }, + "view_manifest": { + "builder_versions": {}, + "canonical_view_types": [], + "local_only_view_types": [], + "version": 1 + } + } + }, + "entries": { + "0": { + "kind": "password", + "label": "bank.example", + "links": [ + { + "note": "recovery", + "relation": "depends_on", + "target_id": "2" + } + ], + "modified_ts": 1700000100, + "tags": [ + "money", + "daily" + ] + }, + "1": { + "archived": true, + "kind": "totp", + "label": "email-2fa", + "modified_ts": 1700000200 + }, + "10": { + "kind": "document", + "label": "notes", + "modified_ts": 1700000400 + }, + "2": { + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "modified_ts": 1700000300, + "tags": [ + "ops", + "\u00fcn\u00ef" + ] + }, + "not-a-number": { + "kind": "password", + "label": "weird id", + "modified_ts": 1700000500 + } + }, + "schema_version": 4 + }, + "views": [ + { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "3bf8aa8e904e217efa279005d1d4c7d5e7dc45f922661733655ca3df9b58a59b", + "view_id": "children_of:seed/C557EEC878DFD852", + "view_type": "children_of" + }, + { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "8dfdf962988e8d9ba0911ffe32325c75c092d2dd7e1bd09a7c9fbdd1ae6dbee7", + "view_id": "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "children_of" + }, + { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "582d4144faeee64620eac8307595876fc66dc1e2e20bfb15c375af04866b6523", + "view_id": "counts_by_kind:seed/C557EEC878DFD852", + "view_type": "counts_by_kind" + }, + { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "17fd876bd4e7b408fe7aa08daf17ff53d3023ea25d21da81996aea407ac84436", + "view_id": "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "counts_by_kind" + }, + { + "data": { + "items": [ + { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "modified_ts": 1700086500, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 3 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "modified_ts": 1700086400, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 2 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "modified_ts": 1700000200, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 1 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "modified_ts": 1700000100, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 0 \u2014 \u00fcn\u00ef" + } + ], + "total_items": 4 + }, + "modified_ts": 1700086500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "2789d66e1f16c659f9edde0c7c3cee89e579640a022ad344a3d50cfc0dfa81e3", + "view_id": "recent_activity:seed/C557EEC878DFD852", + "view_type": "recent_activity" + }, + { + "data": { + "items": [ + { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "modified_ts": 1700090000, + "subject_id": "7", + "subject_kind": "seed", + "summary": "" + } + ], + "total_items": 1 + }, + "modified_ts": 1700090000, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "58b037aa5a5ef62a6dea6f648603c272b03db3c35e62715865a45f4539043b3a", + "view_id": "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "recent_activity" + } + ] +} diff --git a/js/packages/test-vectors/fixtures/manifest.json b/js/packages/test-vectors/fixtures/manifest.json index df00c35e..4ec7a46d 100644 --- a/js/packages/test-vectors/fixtures/manifest.json +++ b/js/packages/test-vectors/fixtures/manifest.json @@ -9,6 +9,7 @@ "entry_secrets.json", "fingerprints.json", "high_risk.json", + "index0.json", "index_keys.json", "kdf_metadata.json", "legacy_payloads.json", @@ -31,6 +32,6 @@ ], "fixture_version": 1, "generator": "scripts/generate_ts_port_fixtures.py", - "python_commit": "885cbe5665498c442ad1d3bebabf5e010db77961", + "python_commit": "64b27d7bf932120392e2b4ff0f46f32de59f26c9", "redaction_policy": "All fixtures derive from public BIP-39 test mnemonics (abandon.../legal.../zoo...). Secret-shaped outputs are generated test material, never from a live profile." } diff --git a/js/packages/test-vectors/src/index.ts b/js/packages/test-vectors/src/index.ts index 7507b811..d52167ed 100644 --- a/js/packages/test-vectors/src/index.ts +++ b/js/packages/test-vectors/src/index.ts @@ -11,6 +11,7 @@ import manifestJson from "../fixtures/manifest.json"; import qrJson from "../fixtures/qr.json"; import semanticJson from "../fixtures/semantic.json"; import highRiskJson from "../fixtures/high_risk.json"; +import index0Json from "../fixtures/index0.json"; import bip39Json from "../fixtures/bip39_seeds.json"; import bip85Json from "../fixtures/bip85_entropy.json"; import passwordsV1Json from "../fixtures/passwords_v1.json"; @@ -344,3 +345,17 @@ export const highRiskFixture = highRiskJson as { entries: Record>; partition_file_b64: string; }; + +/** index0/atlas reference state, produced by the Python implementation. */ +export const index0Fixture = index0Json as { + entries: Record>; + context_root: Record; + context_managed: Record; + appended: Record; + compacted: Record; + manifest_meta: Record; + other: Record; + merged: Record; + views: Array>; + one_view: Record; +}; diff --git a/scripts/generate_ts_port_fixtures.py b/scripts/generate_ts_port_fixtures.py index a53d6e9b..f7324d65 100644 --- a/scripts/generate_ts_port_fixtures.py +++ b/scripts/generate_ts_port_fixtures.py @@ -1379,6 +1379,14 @@ def gen_high_risk() -> dict: salt = bytes(range(16)) partition_key = base64.urlsafe_b64encode(bytes(range(32))).decode("ascii") + # Fernet embeds a random IV and the current time, so `encrypt` would emit + # different bytes on every run and the fixture-drift CI job would fail + # each time it regenerated. `_encrypt_from_parts` pins both. Production + # must never do this -- a repeated IV under one key breaks CBC -- which is + # why it appears only here, in a generator producing test vectors from a + # fixed key. + fixed_iv = bytes(range(16)) + fixed_time = 1700000000 wrapping_key = iso._derive_wrapping_key(factor, salt, iso.PARTITION_KDF_ITERATIONS) envelope = { "version": iso.PARTITION_ENVELOPE_VERSION, @@ -1386,7 +1394,7 @@ def gen_high_risk() -> dict: "iterations": iso.PARTITION_KDF_ITERATIONS, "salt_b64": base64.b64encode(salt).decode("ascii"), "wrapped_partition_key": Fernet(wrapping_key) - .encrypt(partition_key.encode("utf-8")) + ._encrypt_from_parts(partition_key.encode("utf-8"), fixed_time, fixed_iv) .decode("utf-8"), } tag = iso._partition_key_tag(partition_key) @@ -1402,11 +1410,28 @@ def gen_high_risk() -> dict: }, } + # Same reason: build the partition blob with a pinned IV and time rather + # than calling save_partition_entries, whose Fernet output is random. + payload = json.dumps( + { + "schema_version": 1, + "partition": "high_risk", + "updated_at_utc": float(fixed_time), + "entries": entries, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + file_key = hashlib.sha256(tag.encode("utf-8")).digest() + blob = Fernet(base64.urlsafe_b64encode(file_key))._encrypt_from_parts( + payload, fixed_time, fixed_iv + ) + # Prove the pinned construction is what the real writer/reader accept. with tempfile.TemporaryDirectory() as tmpdir: profile = Path(tmpdir) / "PROFILE" profile.mkdir() - store.save_partition_entries(profile, tag, entries) - blob = (profile / store.PARTITION_FILENAME).read_bytes() + (profile / store.PARTITION_FILENAME).write_bytes(blob) + assert store.load_partition_entries(profile, tag) == entries return { "description": "high-risk partition envelope and file, for cross-impl checks", @@ -1419,6 +1444,102 @@ def gen_high_risk() -> dict: } +def gen_index0() -> dict: + """index0/atlas reference state for the TypeScript port. + + Everything in index0 is hashed with SHA-256 over Python's canonical JSON, + and the merge picks winners by comparing those hashes -- so a single byte + of encoding disagreement makes the two implementations permanently + disagree about which record wins. These vectors are compared whole, via + canonical JSON, rather than field by field. + + The inputs deliberately include non-ASCII labels and tags (ensure_ascii + escaping), a non-numeric entry id (view ordering must degrade, not throw), + two writers on two days (checkpoint grouping and retention), and a managed + account (the second scope path). + """ + from seedpass.core import index0 as index0_mod + + fp_dir = "/home/user/.seedpass/C557EEC878DFD852" + managed_dir = "/home/user/.seedpass/C557EEC878DFD852/accounts/AABBCCDDEEFF0011" + + entries = { + "0": {"kind": "password", "label": "bank.example", "modified_ts": 1700000100, + "tags": ["money", "daily"], + "links": [{"target_id": "2", "relation": "depends_on", "note": "recovery"}]}, + "1": {"kind": "totp", "label": "email-2fa", "modified_ts": 1700000200, + "archived": True}, + "2": {"kind": "ssh", "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "modified_ts": 1700000300, "tags": ["ops", "\u00fcn\u00ef"]}, + "10": {"kind": "document", "label": "notes", "modified_ts": 1700000400}, + "not-a-number": {"kind": "password", "label": "weird id", + "modified_ts": 1700000500}, + } + + payload = {"schema_version": 4, "entries": entries} + specs = [ + (1700000100, "entry.created", "0", "password"), + (1700000200, "entry.created", "1", "totp"), + (1700086400, "entry.updated", "0", "password"), + (1700086500, "entry.archived", "1", "totp"), + ] + for i, (ts, event_type, subject_id, subject_kind) in enumerate(specs): + payload = index0_mod.append_index0_event( + payload, + event_type=event_type, + subject_type="entry", + subject_id=subject_id, + subject_kind=subject_kind, + modified_ts=ts, + fingerprint_dir=fp_dir, + tags=["auto"], + summary=f"event {i} \u2014 \u00fcn\u00ef", + ) + payload = index0_mod.append_index0_event( + payload, + event_type="entry.created", + subject_type="entry", + subject_id="7", + subject_kind="seed", + modified_ts=1700090000, + fingerprint_dir=managed_dir, + ) + + compacted = index0_mod.compact_index0_payload(payload, fingerprint_dir=fp_dir) + other = index0_mod.append_index0_event( + {"schema_version": 4, "entries": entries}, + event_type="entry.created", + subject_type="entry", + subject_id="99", + subject_kind="password", + modified_ts=1700099999, + fingerprint_dir=fp_dir, + ) + scope = index0_mod.derive_index0_context(fp_dir)["scope_path"] + + return { + "description": "index0/atlas events, checkpoints, views and merge", + "entries": entries, + "context_root": index0_mod.derive_index0_context(fp_dir), + "context_managed": index0_mod.derive_index0_context(managed_dir), + "appended": payload, + "compacted": compacted, + "manifest_meta": index0_mod.build_manifest_index0_metadata( + payload, fingerprint_dir=fp_dir + ), + "other": other, + "merged": index0_mod.merge_system_index0( + compacted["_system"]["index0"], other["_system"]["index0"] + ), + "views": index0_mod.list_canonical_views(compacted["_system"]["index0"]), + "one_view": index0_mod.get_canonical_view( + compacted["_system"]["index0"], + view_type="counts_by_kind", + scope_path=scope, + ), + } + + def main() -> None: FIXTURES_DIR.mkdir(parents=True, exist_ok=True) @@ -1445,6 +1566,7 @@ def main() -> None: "qr.json": gen_qr(), "semantic.json": gen_semantic(), "high_risk.json": gen_high_risk(), + "index0.json": gen_index0(), } entries_fixture, vault_fixture = gen_entries_and_vault() files["entries_index.json"] = entries_fixture