Files
SeedPass/js/packages/core/test/unknownKinds.test.ts
T
thePR0M3TH3ANandClaude Opus 5 e434b0abc1 feat(schema): carry unknown entry kinds through instead of refusing the vault
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
2026-08-18 13:26:32 -04:00

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();
});
});