mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 16:32:06 +00:00
Spec section 8.2: a record whose kind this build does not understand -- one written by a newer build, or by another application sharing the vault, such as a future BitLogin namespaced kind -- must be carried through untouched, never dropped, and must not brick the whole index. TS previously failed the entire parse on the first unrecognized kind, stranding any shared vault on older SeedPass builds. The entry schema is now a union: known kinds validate strictly against the discriminated union exactly as before, and anything else parses as an opaque loose record. The refine on the opaque branch is the load-bearing detail -- a malformed record of a KNOWN kind must still fail its own schema, never fall through and escape validation. Records with no kind or type at all remain rejected as garbage. Statically the entries map stays typed to the known kinds (exhaustive switches keep their meaning); the module comment states the runtime widening plainly. At the CLI surface, foreign records list with unrecognized fields redacted to has_* flags -- another application's data is not ours to hand out -- and reveal refuses with "unsupported entry kind" instead of guessing. Allocation counts foreign ids, so neighbours never collide with them. Python's natural tolerance is now pinned by test, which surfaced a real nuance: Python backfills base-field defaults (tags, links, date_modified) onto foreign records on load, where TS carries them verbatim. No data is lost, so both conform to the preservation rule as now worded (no loss, no reinterpretation; base-field normalization permitted) -- but the two produce different canonical bytes for the same foreign record, which matters once foreign kinds sync. Recorded in the spec's gap ledger to align before any application ships them. Verified: 4 of the 6 new TS tests fail against the previous schema; 230 core + 152 CLI tests and the cross-impl suite pass with the change. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
108 lines
3.7 KiB
TypeScript
108 lines
3.7 KiB
TypeScript
/**
|
|
* Unknown-kind passthrough (spec §8.2): a record whose kind this build does
|
|
* not understand is carried through verbatim — never dropped, never a parse
|
|
* failure that bricks the whole vault. The counterweight: this must not
|
|
* become a validation bypass, so a malformed record of a KNOWN kind still
|
|
* fails loudly.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
parseVaultIndex,
|
|
addPasswordEntry,
|
|
encryptV3,
|
|
decryptPayload,
|
|
deriveIndexKeyBytes,
|
|
utf8,
|
|
type VaultIndex,
|
|
} from "@seedpass/core";
|
|
import { mnemonics } from "@seedpass/test-vectors";
|
|
|
|
const MNEMONIC = mnemonics["abandon12"]!;
|
|
|
|
/** A record shaped like something a future identity client would write. */
|
|
const FOREIGN_RECORD = {
|
|
kind: "bitlogin_org",
|
|
type: "bitlogin_org",
|
|
label: "Acme Corporation",
|
|
modified_ts: 1700000123,
|
|
bitlogin: {
|
|
admins: ["npub1aaaa"],
|
|
roles: { sales: ["npub1bbbb"] },
|
|
policy_rev: 7,
|
|
},
|
|
};
|
|
|
|
function indexWithForeignRecord(): Record<string, unknown> {
|
|
return {
|
|
schema_version: 4,
|
|
entries: {
|
|
"0": {
|
|
type: "key_value",
|
|
kind: "key_value",
|
|
label: "api",
|
|
key: "k",
|
|
value: "v",
|
|
archived: false,
|
|
notes: "",
|
|
tags: [],
|
|
links: [],
|
|
},
|
|
"1": FOREIGN_RECORD,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("unknown entry kinds pass through", () => {
|
|
it("parses an index containing a foreign record and preserves it verbatim", () => {
|
|
const index = parseVaultIndex(indexWithForeignRecord());
|
|
expect(index.entries["1"]).toEqual(FOREIGN_RECORD);
|
|
// The known record beside it still validated normally.
|
|
expect(index.entries["0"]!.kind).toBe("key_value");
|
|
});
|
|
|
|
it("survives a full encrypt → decrypt → parse → re-encrypt round trip", async () => {
|
|
const key = deriveIndexKeyBytes(MNEMONIC);
|
|
const blob = await encryptV3(key, utf8(JSON.stringify(indexWithForeignRecord())));
|
|
const first = parseVaultIndex(JSON.parse(new TextDecoder().decode(await decryptPayload(key, blob))));
|
|
// What saveVault does before writing: re-validate without migration.
|
|
const revalidated = parseVaultIndex(JSON.parse(JSON.stringify(first)), { migrate: false });
|
|
const blob2 = await encryptV3(key, utf8(JSON.stringify(revalidated)));
|
|
const second = parseVaultIndex(JSON.parse(new TextDecoder().decode(await decryptPayload(key, blob2))));
|
|
expect(second.entries["1"]).toEqual(FOREIGN_RECORD);
|
|
});
|
|
|
|
it("typed operations keep working around the foreign record", () => {
|
|
const index = parseVaultIndex(indexWithForeignRecord());
|
|
// Allocation sees the foreign record's id and does not collide with it.
|
|
const id = addPasswordEntry(index as VaultIndex, "site", 20, {});
|
|
expect(id).toBe("2");
|
|
expect(index.entries["1"]).toEqual(FOREIGN_RECORD);
|
|
});
|
|
|
|
it("fills kind from type for foreign records too (legacy shape)", () => {
|
|
const raw = indexWithForeignRecord();
|
|
const entries = raw["entries"] as Record<string, Record<string, unknown>>;
|
|
delete entries["1"]!["kind"];
|
|
const index = parseVaultIndex(raw);
|
|
expect((index.entries["1"] as unknown as Record<string, unknown>)["kind"]).toBe("bitlogin_org");
|
|
});
|
|
|
|
it("does NOT let a malformed known kind escape through the passthrough", () => {
|
|
const raw = indexWithForeignRecord();
|
|
(raw["entries"] as Record<string, unknown>)["2"] = {
|
|
kind: "password",
|
|
type: "password",
|
|
label: "broken",
|
|
// length missing: must fail strict validation, not pass as opaque
|
|
};
|
|
expect(() => parseVaultIndex(raw)).toThrow();
|
|
});
|
|
|
|
it("still rejects records with no kind or type at all", () => {
|
|
const raw = indexWithForeignRecord();
|
|
(raw["entries"] as Record<string, unknown>)["2"] = { label: "mystery" };
|
|
expect(() => parseVaultIndex(raw)).toThrow();
|
|
});
|
|
});
|