The "semantic (vector search)" entry in not_yet_ported was a misnomer. It is
not vector search and needs no model: MODEL_ID is "seedpass-token-overlap-v1",
and it scores by Jaccard overlap over tokenized metadata. That is why it could
be ported at all — there is nothing to embed.
Porting it surfaced two defects in the Python implementation.
**Stored secrets were written to a plaintext, 0664 file.** `_extract_text`
appended a key_value entry's `value` — the stored secret itself — and
`build()` wrote it to semantic_index/records.json in the clear, along with a
tokenized copy that leaked it just as thoroughly. The file was created at the
process umask, so on a default install it was group- and world-readable, next
to a vault that is encrypted and 0600. Anything that could read the directory
— a backup, a sync client, another user on a shared machine — got the secret
without the master password.
Fixed in Python (index the key NAME, never the value; chmod 0600 on both
files) and never done in TypeScript. The rule is worth keeping simple: nobody
searches for a secret they do not already know, so indexing one is pure
downside. MODEL_ID is bumped to v2 so an index built by the old code is
distinguishable — a v1 index on disk still contains the secrets and should be
rebuilt.
**Entry 0 was silently unsearchable.** The check was
`int(entry.get("id", 0) or 0) <= 0`, conflating "no id" with "id 0" — and
entry ids start at 0, so the first entry any user ever created was absent from
every search result with nothing to indicate why. Found by writing a test that
queried for it. Fixed in both implementations: skip an id that is absent,
unparseable or negative, which is what the check was reaching for.
The TypeScript port is wired into the CLI (`semantic build/status/search`),
the API (the /api/v1/semantic routes are now real rather than 501), and
writes through atomicWrite for a genuine 0600 inode. Fixtures come from
scripts/generate_ts_port_fixtures.py, which asserts the leaked value is absent
from its own output, so the fixture-drift job proves provenance the way it
does for every other fixture.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
Closes the open "does the api get a TypeScript port at all" decision by
porting the subset that is not built on unported subsystems: entries (list,
search, read, create, modify, archive, links, document import/export), an
explicit secret route, config, profiles, TOTP, vault lock/unlock/export/import,
change-password, relays, nostr pubkey, stats, notifications, derivation check,
and shutdown.
Built on node:http with no framework. The CLI ships as a single audited bundle
with an empty production dependency list, and adding a web framework to a
process that holds unlocked parent seeds would be the largest supply-chain
change in the project.
This surface binds a TCP port while holding an unlocked seed, which is
strictly more exposed than the agent's 0600 unix socket, so it is shaped by
what it refuses:
- Loopback only. A non-loopback bind requires --allow-remote; an accidental
--host 0.0.0.0 on a seed-holding process is not a mistake that should be
recoverable after the fact.
- Bearer token on every route, printed once, never written to disk,
compared in constant time.
- Routes that produce plaintext additionally require the master password in
a header, so a token lifted from a script or a process list cannot read
the vault on its own.
- Path parameters resolve inside the profile directory or 400.
- Body size capped while the request is still arriving.
- Failed unlocks get their own much tighter budget, and exhausting it
returns 429 even for the correct password, so the port is not a password
oracle and lockout is not distinguishable from a wrong guess.
- No CORS headers unless the origin was explicitly allowlisted.
- password_hash / pin_hash are never returned and cannot be set.
Every handler goes through the CLI's own vault plumbing rather than
reimplementing it. That is the point: an API deriving secrets its own way
would be a second implementation to keep in parity, and the first place the
two would drift is which secret a given entry produces. Verified end to end
against a real running server: Python, the TS CLI and the TS API all derive
g'xH9E?3n3VS8EOmX8 for the same entry.
Routes belonging to deliberately unported subsystems (high-risk partitions,
agent job profiles, agent recovery split, semantic index) answer 501 naming
the feature rather than 404 — a 404 reads as a typo and would send an
integrator hunting for a spelling mistake that is not there.
One bug the tests caught before it shipped: the 2FA export returned
materializeSecret's value for deterministic TOTP entries, which is the current
six-digit CODE, not the base32 secret. An authenticator cannot be seeded from
a code, so the export would have looked correct and been useless. It now
re-derives the secret the way the TUI's export does, and returns the otpauth
URI alongside it.
28 API tests, most of them about what is refused rather than what works.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN