diff --git a/js/packages/cli/src/api/routes.ts b/js/packages/cli/src/api/routes.ts index 95ae2507..c8e039e2 100644 --- a/js/packages/cli/src/api/routes.ts +++ b/js/packages/cli/src/api/routes.ts @@ -205,11 +205,11 @@ async function mutate( try { const updated = emitEntryEvents(vault.index, { before, - after: vault.index.entries as unknown as Record, + after: vault.index.entries, fingerprintDir: profileDir(ctx), now: Math.floor(ctx.now() / 1000), }); - (vault.index as unknown as Record)["_system"] = updated["_system"]; + (vault.index)["_system"] = updated["_system"]; } catch { // Derived state; never fail a committed write over it. } @@ -284,7 +284,7 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void { const rows = Object.entries(vault.index.entries) .map(([id, entry]) => ({ id, entry: entry as Entry })) .filter(({ entry }) => { - const e = entry as unknown as Record; + const e = entry; if (!includeArchived && e["archived"] === true) return false; if (kindFilter && String(e["kind"] ?? e["type"] ?? "") !== kindFilter) return false; if (!query) return true; @@ -631,9 +631,9 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void { const vault = await readVault(ctx); const now = Math.floor(ctx.now() / 1000); const codes = Object.entries(vault.index.entries) - .filter(([, e]) => String((e as unknown as Record)["kind"] ?? "") === "totp") + .filter(([, e]) => String((e)["kind"] ?? "") === "totp") .map(([id, e]) => { - const entry = e as unknown as Record; + const entry = e; const secret = materializeSecret(vault.index, id, e as Entry, mnemonic, { timestamp: now, }); @@ -660,9 +660,9 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void { // Every TOTP secret in the vault, in plaintext. Password-gated for // that reason alone. const entries = Object.entries(vault.index.entries) - .filter(([, e]) => String((e as unknown as Record)["kind"] ?? "") === "totp") + .filter(([, e]) => String((e)["kind"] ?? "") === "totp") .map(([, e]) => { - const entry = e as unknown as Record; + const entry = e; // The SECRET, not a code. materializeSecret returns the current // 6-digit code for a totp entry, which would make this export look // correct and be useless — an authenticator cannot be seeded from @@ -894,7 +894,7 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void { const counts: Record = {}; let archived = 0; for (const entry of Object.values(vault.index.entries)) { - const e = entry as unknown as Record; + const e = entry; const kind = String(e["kind"] ?? e["type"] ?? "unknown"); counts[kind] = (counts[kind] ?? 0) + 1; if (e["archived"] === true) archived++; diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index cf7f8f25..7c251df3 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -417,11 +417,11 @@ function recordIndex0( try { const updated = emitEntryEvents(vault.index, { before, - after: vault.index.entries as unknown as Record, + after: vault.index.entries, fingerprintDir, now: Math.floor(Date.now() / 1000), }); - (vault.index as unknown as Record)["_system"] = updated["_system"]; + (vault.index)["_system"] = updated["_system"]; } catch { // Deliberately swallowed; see the note above. } @@ -476,7 +476,7 @@ async function openReadAccess(opts: GlobalOpts): Promise { * it needs the second factor to be live in the agent. */ const hydrate = async (id: string, entry: Entry): Promise => { - const record = entry as unknown as Record; + const record = entry; if (!isPartitionStub(record)) return entry; const client = new AgentClient(agentSocketPath(app.root)); const fp = await currentFingerprint(app, opts); @@ -1478,10 +1478,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { let local: Record | null = null; if (localExists) { try { - local = (await openVault(vaultPath, mnemonic)).index as unknown as Record< - string, - unknown - >; + local = (await openVault(vaultPath, mnemonic)).index; } catch { local = null; // unreadable local vault: treat the restore as recovery } @@ -2026,12 +2023,17 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { await mutateVault(opts, async (vault) => { const partition = await readPartition(profileDir, tag); for (const [id, raw] of Object.entries(vault.index.entries)) { - const entry = raw as unknown as Record; + const entry = raw; if (isPartitionStub(entry)) continue; // already moved const kind = String(entry["kind"] ?? entry["type"] ?? "").toLowerCase(); if (!kinds.has(kind)) continue; partition[id] = entry; - (vault.index.entries as unknown as Record)[id] = + // Same single assertion as entryOps.insert, for the same reason: + // partitionStub builds a valid stub, but `kind` is a runtime string + // and the entry union is keyed on literals, so the compiler cannot + // see the two line up. Validated on the way back out by + // parsePartitionRecord. + (vault.index.entries as Record>)[id] = partitionStub(id, entry, kind, Date.now() / 1000); moved.push(id); } @@ -2487,7 +2489,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { const byKind: Record = {}; let archived = 0; for (const entry of Object.values(vault.index.entries)) { - const e = entry as unknown as Record; + const e = entry; const kind = String(e["kind"] ?? e["type"] ?? "unknown"); byKind[kind] = (byKind[kind] ?? 0) + 1; if (e["archived"] === true) archived++; diff --git a/js/packages/cli/src/recoveryDrills.ts b/js/packages/cli/src/recoveryDrills.ts index c273ef8b..be7e5a52 100644 --- a/js/packages/cli/src/recoveryDrills.ts +++ b/js/packages/cli/src/recoveryDrills.ts @@ -73,7 +73,16 @@ function canonical(value: unknown): string { .join(",")}}`; } -function sign(key: Uint8Array, previousSig: string, record: DrillRecord): string { +// Takes any record: it HMACs a canonical serialization, so the shape is not +// something it needs or checks. Verification re-signs a parsed line and +// compares — a wrong shape simply fails to match, which is reported as +// sig_mismatch. Declaring DrillRecord here made the verifier assert a shape +// it had not established, to satisfy a parameter that never used it. +function sign( + key: Uint8Array, + previousSig: string, + record: DrillRecord | Record, +): string { return hmacSha256Hex(key, utf8(`${previousSig}${canonical(record)}`)); } @@ -209,7 +218,7 @@ export async function verifyRecoveryDrills( continue; } delete body["sig"]; - if (sign(key, previousSig, body as unknown as DrillRecord) !== sig) { + if (sign(key, previousSig, body) !== sig) { errors.push(`sig_mismatch_line:${index}`); } // Chain forward on the RECORDED signature, not the computed one: a single diff --git a/js/packages/cli/src/tui/console.ts b/js/packages/cli/src/tui/console.ts index 8a1c7b8a..9413172c 100644 --- a/js/packages/cli/src/tui/console.ts +++ b/js/packages/cli/src/tui/console.ts @@ -53,7 +53,9 @@ export class ConsoleUi implements Ui { } clear(): void { - if ((this.out as unknown as { isTTY?: boolean }).isTTY) { + // `in` narrows instead of asserting: NodeJS.WritableStream has no isTTY, + // but the process streams do, and this asks rather than declares. + if ("isTTY" in this.out && this.out.isTTY) { // Clear and reset the cursor, then clear scrollback: menus redraw // constantly, and without the third sequence a session leaves hundreds // of stale screens behind it. diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts index d2c50602..d5c5a7e8 100644 --- a/js/packages/cli/src/tui/menus.ts +++ b/js/packages/cli/src/tui/menus.ts @@ -123,7 +123,7 @@ function title(s: Session, breadcrumb: string): void { } function kindOf(entry: Entry): string { - const e = entry as unknown as Record; + const e = entry; return String(e["kind"] ?? e["type"] ?? "unknown"); } @@ -134,7 +134,7 @@ function kindOf(entry: Entry): string { * seed at the entry's index — the same secret the code is computed from. */ function totpUri(entry: Entry, mnemonic: string): string { - const e = entry as unknown as Record; + const e = entry; const secret = typeof e["secret"] === "string" && e["secret"] ? (e["secret"] as string) @@ -174,11 +174,11 @@ async function mutate(s: Session, fn: (vault: OpenedVault) => void): Promise, + after: fresh.index.entries, fingerprintDir: s.app.profileDir(s.fingerprint), now: Math.floor(s.clock() / 1000), }); - (fresh.index as unknown as Record)["_system"] = updated["_system"]; + (fresh.index)["_system"] = updated["_system"]; } catch { // Derived state; never fail a committed write over it. } @@ -1576,7 +1576,7 @@ async function exportTotpCodes(s: Session): Promise { ({ entry }) => kindOf(entry) === "totp" && !isArchived(entry), ); const uris = rows.map(({ entry }) => ({ - label: String((entry as unknown as Record)["label"] ?? ""), + label: String((entry)["label"] ?? ""), uri: totpUri(entry, s.vault.mnemonic), })); // This file is every 2FA secret in the vault, in plaintext. diff --git a/js/packages/core/src/schema/entries.ts b/js/packages/core/src/schema/entries.ts index 3aaf4dca..a86905d9 100644 --- a/js/packages/core/src/schema/entries.ts +++ b/js/packages/core/src/schema/entries.ts @@ -250,6 +250,18 @@ export const partitionStubSchema = z }) .loose(); +// The one assertion chain left in the codebase, and it is structural rather +// than evidential. `z.preprocess` types its output from the inner union, +// which here is `partitionStub | knownKinds | unknownKind` — a wider type +// than `Entry` by construction, because a stub and an unknown-kind record are +// both legitimate index members that no typed operation should touch. Every +// consumer wants `Entry`. There is no zod spelling that expresses "parses to +// a superset, hands back the narrow type" without an assertion. +// +// It is safe in the way the rule cares about: nothing is being CLAIMED about +// unvalidated data. The value has just been parsed, one line above, by the +// schema being cast. +// oxlint-disable-next-line anti-slop/no-chained-type-assertions export const entrySchema = z.preprocess( withKind, // The stub comes first: it is the narrower shape, and a stub of a known diff --git a/js/packages/core/src/vault/derivationCollisions.ts b/js/packages/core/src/vault/derivationCollisions.ts index 17848fcc..1c03513c 100644 --- a/js/packages/core/src/vault/derivationCollisions.ts +++ b/js/packages/core/src/vault/derivationCollisions.ts @@ -73,7 +73,7 @@ export function findDerivationCollisions(index: VaultIndex): DerivationCollision const byIndex = new Map>(); for (const [id, raw] of Object.entries(index.entries)) { - const entry = raw as unknown as Record; + const entry = raw; const kind = String(entry["kind"] ?? entry["type"] ?? ""); if (!(APP32_KINDS as readonly string[]).includes(kind)) continue; const derivationIndex = derivationIndexOf(id, entry); diff --git a/js/packages/core/src/vault/entryMod.ts b/js/packages/core/src/vault/entryMod.ts index 3618b730..a9c915c7 100644 --- a/js/packages/core/src/vault/entryMod.ts +++ b/js/packages/core/src/vault/entryMod.ts @@ -96,7 +96,7 @@ const ALLOWED_FIELDS: Record> = { }; function entryDict(index: VaultIndex, id: string): Dict { - const entry = (index.entries as unknown as Record)[id]; + const entry = (index.entries)[id]; if (typeof entry !== "object" || entry === null) { throw new Error(`Entry not found: ${id}`); } @@ -211,7 +211,7 @@ export interface ResolvedLink extends EntryLink { /** Links for an entry with resolved target metadata. */ export function getLinks(index: VaultIndex, id: string): ResolvedLink[] { const src = entryDict(index, id); - const entries = index.entries as unknown as Record; + const entries = index.entries; return normalizeLinks(src["links"] ?? []).map((link) => { const target = entries[String(link.target_id)]; return { diff --git a/js/packages/core/src/vault/entryOps.ts b/js/packages/core/src/vault/entryOps.ts index 67f542eb..35fceb93 100644 --- a/js/packages/core/src/vault/entryOps.ts +++ b/js/packages/core/src/vault/entryOps.ts @@ -75,7 +75,7 @@ export function nextIndex(index: VaultIndex): number { /** The persisted allocation watermark, 0 when absent (pre-watermark vault). */ function storedNextIndex(index: VaultIndex): number { - const meta = (index as unknown as Dict)["_sync_meta"]; + const meta = (index)["_sync_meta"]; if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return 0; const raw = Number((meta as Dict)["next_index"]); return Number.isSafeInteger(raw) && raw > 0 ? raw : 0; @@ -83,7 +83,7 @@ function storedNextIndex(index: VaultIndex): number { /** One past the highest tombstoned id. Non-numeric tombstone keys are historical junk and skipped. */ function tombstoneFloor(index: VaultIndex): number { - const meta = (index as unknown as Dict)["_sync_meta"]; + const meta = (index)["_sync_meta"]; if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return 0; const tombstones = (meta as Dict)["tombstones"]; if (typeof tombstones !== "object" || tombstones === null || Array.isArray(tombstones)) return 0; @@ -103,7 +103,7 @@ function tombstoneFloor(index: VaultIndex): number { * preserve it without a schema bump. */ function bumpNextIndex(index: VaultIndex, id: number): void { - const container = index as unknown as Dict; + const container = index; const meta = typeof container["_sync_meta"] === "object" && container["_sync_meta"] !== null && @@ -116,8 +116,12 @@ function bumpNextIndex(index: VaultIndex, id: number): void { /** Next TOTP derivation index: max over totp entries' index field + 1. */ export function nextTotpIndex(index: VaultIndex): number { - const indices = Object.values(index.entries as unknown as Record) - .filter((e) => e["type"] === "totp" || e["kind"] === "totp") + const indices = Object.values(index.entries) + // `kind` alone, not `type || kind`. withKind() populates kind from type + // during parsing and the entry union is discriminated on it, so the + // second half of that condition could never be true — the compiler said + // so the moment the casts stopped blinding it. + .filter((e) => e["kind"] === "totp") .map((e) => Math.trunc(Number(e["index"] ?? 0))); return indices.length > 0 ? Math.max(...indices) + 1 : 0; } @@ -133,7 +137,10 @@ function insert(index: VaultIndex, id: number, entry: Dict): string { if (Object.prototype.hasOwnProperty.call(index.entries, key)) { throw new Error(`refusing to overwrite existing entry ${key}`); } - (index.entries as unknown as Dict)[key] = entry; + // One direct assertion, not a chain: `insert` receives an entry the caller + // has just assembled field by field, and a literal's `type: "totp"` widens + // to string without a const assertion at every call site. + (index.entries as Record)[key] = entry; // Covers explicit-id inserts too: an entry created at #500 pushes the // watermark past 500 even though nextIndex never saw it. bumpNextIndex(index, id); diff --git a/js/packages/core/src/vault/index0.ts b/js/packages/core/src/vault/index0.ts index 8d21686f..f7c97ee1 100644 --- a/js/packages/core/src/vault/index0.ts +++ b/js/packages/core/src/vault/index0.ts @@ -444,13 +444,27 @@ export function deriveIndex0Context( }; } +/** + * A payload whose `_system.index0` has been through normalizeIndex0. + * + * The type exists so the guarantee survives the return. ensureIndex0Payload + * always normalized, but declared `Dict`, so all three callers immediately + * asserted the knowledge back with `as unknown as Index0` — three chained + * assertions standing in for a fact the function had already established one + * line earlier. + */ +export type Index0Payload = Dict & { _system: Dict & { index0: Index0 } }; + /** 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; +export function ensureIndex0Payload(data: unknown): Index0Payload { + const base = isDict(data) ? { ...data } : {}; + const system = isDict(base["_system"]) ? { ...(base["_system"] as Dict) } : {}; + // Built in the shape it promises rather than assembled and then asserted, + // so nothing here has to be taken on trust. + return { + ...base, + _system: { ...system, index0: normalizeIndex0(system["index0"]) }, + }; } // ------------------------------------------------------------------- events @@ -528,7 +542,7 @@ export interface AppendEventOptions extends Omit< */ export function appendIndex0Event(payload: unknown, options: AppendEventOptions): Dict { const out = ensureIndex0Payload(payload); - const system = (out["_system"] as Dict)["index0"] as unknown as Index0; + const system = out._system.index0; const context = deriveIndex0Context(options.fingerprintDir, { ...(options.actorType !== undefined && { actorType: options.actorType }), }); @@ -609,7 +623,10 @@ export function buildDailyCheckpoint( } export function rebuildIndex0Checkpoints( - index0: Dict, + // Reads only `events`, like recomputeIndex0Stats reads only three fields. + // Typed to that rather than to a bare Dict, so an Index0 goes in without a + // cast. + index0: Pick, options: { maxCheckpointsPerWriter?: number } = {}, ): Record { const maxPerWriter = options.maxCheckpointsPerWriter ?? INDEX0_MAX_CHECKPOINTS_PER_WRITER; @@ -661,7 +678,7 @@ export function compactIndex0( ): Index0 { const normalized = normalizeIndex0(index0); normalized.checkpoints = rebuildIndex0Checkpoints( - normalized as unknown as Dict, + normalized, options, ); normalized.stats = recomputeIndex0Stats(normalized); @@ -789,9 +806,9 @@ function buildRecentActivityView( export function rebuildCanonicalViewsPayload( payload: unknown, options: { fingerprintDir?: string | null } = {}, -): Dict { +): Index0Payload { const out = ensureIndex0Payload(payload); - const index0 = (out["_system"] as Dict)["index0"] as unknown as Index0; + const index0 = out._system.index0; const entries = normalizeMapping(out["entries"]); const events = Object.values(index0.events) .map((event) => normalizeIndex0Event(event)) @@ -864,9 +881,9 @@ export function rebuildCanonicalViewsPayload( export function compactIndex0Payload( payload: unknown, options: { maxCheckpointsPerWriter?: number; fingerprintDir?: string | null } = {}, -): Dict { +): Index0Payload { const out = ensureIndex0Payload(payload); - (out["_system"] as Dict)["index0"] = compactIndex0((out["_system"] as Dict)["index0"], { + out._system.index0 = compactIndex0(out._system.index0, { ...(options.maxCheckpointsPerWriter !== undefined && { maxCheckpointsPerWriter: options.maxCheckpointsPerWriter, }), @@ -885,7 +902,7 @@ export function buildManifestIndex0Metadata( const compacted = compactIndex0Payload(payload, { ...(options.fingerprintDir !== undefined && { fingerprintDir: options.fingerprintDir }), }); - const index0 = (compacted["_system"] as Dict)["index0"] as unknown as Index0; + const index0 = compacted._system.index0; const checkpoints = Object.values(index0.checkpoints).filter(isDict); const selected = [...checkpoints] .sort((a, b) => { diff --git a/scripts/ci_prepush.sh b/scripts/ci_prepush.sh index fc84db00..2c2aaf0b 100755 --- a/scripts/ci_prepush.sh +++ b/scripts/ci_prepush.sh @@ -13,7 +13,7 @@ # preflight 2.6s # poetry check --lock 0.6s # black --check 2.5s -# oxlint 0.9s (not enabled yet, see below) +# oxlint 0.9s # tsc --noEmit (workspace) 7.3s # pytest determinism subset 5.4s # vitest core (node) 24.0s @@ -50,14 +50,8 @@ poetry check --lock step "python: formatting (the pinned black, not whatever is on PATH)" poetry run black --check . -# NOT YET. `pnpm -C js lint` currently exits non-zero on 35 known chained type -# assertions -- one design problem (VaultIndex does not model what a vault -# index contains) counted 35 times. Putting a red check in the push gate is -# how a gate becomes something people route around, so it goes in when it goes -# green, not before. Track it with: pnpm -C js lint -# -# step "javascript: lint" -# pnpm -C js lint +step "javascript: lint" +pnpm -C js lint step "javascript: typecheck" pnpm -C js typecheck