The deterministic merge is frozen for parity -- both implementations must
reach the same vault from the same inputs -- so how it resolves is not up for
negotiation. What was missing is that nobody is told a resolution happened.
L-2: the allocation watermark is per-replica, so two devices working offline
can both allocate id N. The merge then resolves that id by timestamp then
hash, and the loser is replaced with no trace. An entry the user created is
simply absent afterwards. Worse than a plain overwrite, because an id is a
permanent BIP-85 derivation coordinate: the survivor derives from the same
coordinate the discarded entry did (see the L-1 detector).
L-3: past TOMBSTONE_RETENTION_CAP the oldest deletions are forgotten, and
merging a stale replica or old snapshot then resurrects those entries. A
documented protocol limit -- but a user cannot know the cap has actually begun
trimming their history unless something says so.
Both implementations gain an optional MergeReport collector: same-id conflicts
(with the kept and discarded entries named) and a count of evicted tombstones.
`nostr restore` reports both in its summary and on stderr, covering delta
replay as well as the final local merge.
Only genuine divergences are reported. Two replicas holding the same entry
with different timestamps is an ordinary edit and is ignored; flagging it
would bury the case that matters in noise and train the user past the warning.
The collector is write-only and cannot change the merged result -- asserted in
both implementations, because if observing could alter the outcome it would be
a parity divergence rather than an observation.
Two defects found while writing the tests:
- Python's merge MUTATED its `current` argument. `dict(current)` shares the
nested entries dict, so the merge wrote its result straight into the
caller's payload: after merging, "current" had already become the merged
value. Anything comparing before-and-after, or merging one payload twice,
silently saw the wrong input. Now deep-copied, matching TypeScript's
structuredClone. Found because the "reported whichever side wins" test
failed -- the forward merge had corrupted the input for the backward one.
- test_merge_replay_same_payload_is_idempotent was passing only because of
that aliasing: `once` and `twice` were one object. With inputs no longer
mutated, replaying the same payload is shown to normalize entry SHAPE --
the equal-timestamp path unions field sets and writes explicit nulls for
absent optional fields. TypeScript produces byte-identical output for the
same input, so this is shared designed behaviour rather than a Python
regression, and it converges on the second pass. The test now asserts what
is actually true (content unchanged, shape normalized, settles at two) and
records why the old assertion held.
Mutation-verified: removing the recording turns the reporting tests red.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
seedpass-js
The SeedPass command-line interface, TypeScript port.
It reads and writes the same ~/.seedpass profiles as the Python
implementation — same derivations, same vault format, same Nostr sync — and
adds a session agent plus a scoped-token surface designed for AI agents and
automation.
Install
Requires Node.js 22 or newer.
# from a checkout
cd js && pnpm install && pnpm --filter @seedpass/cli build
node packages/cli/dist/seedpass-js.mjs --version
The build produces one self-contained file (dist/seedpass-js.mjs) plus a
.sha256 next to it. Copy it anywhere on your PATH; it has no runtime
dependencies beyond Node itself.
Quick start
export SEEDPASS_APP_DIR=~/.seedpass # omit to use the default
# Create a profile (mnemonic and password come from the environment,
# never from the command line)
export SEEDPASS_MNEMONIC="your twelve or twenty-four words"
export SEEDPASS_PASSWORD="your master password"
seedpass-js fingerprint add --name main
unset SEEDPASS_MNEMONIC # not needed again
# Unlock once per session through the agent
seedpass-js agent start &
seedpass-js vault unlock --ttl 900
seedpass-js entry add password github.com --length 20 --username you
seedpass-js entry reveal github.com
seedpass-js entry totp-codes
seedpass-js use github.com --clipboard
seedpass-js vault lock
Design: reference-first output
Default output never contains secret values. Entries are addressed by
reference (sp://entry/<id>), and secret-bearing fields are reported as
has_* flags:
$ seedpass-js entry get api-token
{ "id": "5", "ref": "sp://entry/5", "label": "api-token",
"kind": "key_value", "has_value": true, ... }
Plaintext leaves the vault only through commands that say so
(entry reveal, entry totp-codes, entry export-document,
util generate-password), or through a sink, which delivers a secret to its
destination without routing it through this process's output:
seedpass-js use api-token --exec ./deploy.sh # SEEDPASS_SECRET in the child env
seedpass-js use api-token --stdin-to "wc -c" # quote specs that contain flags
seedpass-js use api-token --clipboard
Scoped tokens for agents
An agent can be given a token that grants exactly what it needs and nothing else. Secrets are materialized inside the session agent, so the token holder never receives the seed and can never escalate to owner operations.
# As the owner:
seedpass-js agent token-issue --scope read use \
--kind key_value --label-regex '^api-' --uses 2 --ttl 600
# In the agent's process — no mnemonic, no password:
export SEEDPASS_TOKEN=<token>
seedpass-js entry list # metadata only
seedpass-js use api-token --exec ./deploy.sh # permitted
seedpass-js entry reveal api-token # denied: scope 'reveal' not granted
# Back as the owner — every grant, delivery and denial is recorded:
seedpass-js agent audit-verify
seedpass-js agent audit-tail -n 20
Environment
| Variable | Purpose |
|---|---|
SEEDPASS_APP_DIR |
Profile directory (default ~/.seedpass) |
SEEDPASS_MNEMONIC |
Parent seed; needed for profile creation, otherwise use the agent |
SEEDPASS_PASSWORD |
Master password, for fingerprint add and vault unlock |
SEEDPASS_TOKEN |
Scoped token; puts the CLI in token mode |
SEEDPASS_AGENT_SOCK |
Override the agent socket path |
SEEDPASS_SECRET |
Set by use --exec in the child process only |
Compatibility
Interoperability with the Python implementation is verified on every change
by scripts/cross_impl_check.py: each side opens the other's profiles,
backups round-trip, edits are mutually visible, conflict merges agree, and a
snapshot published by Python restores here with secrets intact. See
docs/typescript_port_compatibility_matrix.md.
Not yet ported: PGP RSA keys (not byte-reproducible; ed25519 is at parity),
and the semantic and api command groups.