# Wallet — the extraction record Extracted on **2026-08-19**, the tenth plugin out. `plugins/EXTRACTING-A-PLUGIN.md` is the runbook, and `docs/wallet-key-custody.md` in the platform is authoritative for the crypto — read that before touching `sidecar/keys.ts`. ## The decision this extraction actually turned on The wallet permission was **`kind: 'admin'`** — owner only, not grantable, invisible to every other account. **Plugin permissions are always `kind: 'app'`**, which means grantable: `mount.ts` forces it and the manifest has no field for anything else, deliberately, so that "can a plugin escalate itself" is a question that cannot be asked. So extracting this could not preserve `admin`. That was raised as a blocker before any code moved, because turning "the server owner's bitcoin" into a row in the grants list next to Photos and Music is the kind of change nobody notices until someone ticks it. **It was the right change anyway, and for a reason that inverts the objection.** Every wallet query is scoped by `user_id`, and that id comes from `X-Officer-User`, injected by the platform from the authenticated session — a caller cannot name someone else's. So granting this permission gives an account **its own wallet**. It does not hand over anyone else's. `kind: 'admin'` was single-user thinking. Grantable is the mechanism by which a second person gets a wallet at all. ## What that means for isolation, precisely | Who | Can they reach another user's wallet? | | -------------------------------------- | ----------------------------------------------------------------------------------- | | A member, through the API | No. Queries filter on `user_id`, including fetch-by-id, so guessing an id gets nothing. | | The OWNER, through the API | No. The owner bypasses permission checks, not query scoping — `listWallets` returns the caller's. | | The owner, with root and the database | Can read **watch-only** data. Cannot spend. | | Anyone, wanting to spend | Needs that wallet's own passphrase. scrypt N=2¹⁷, ~1s and 128 MiB per guess, and the server never stores it. | ## The gap that is real, and deferred on purpose Watch-only data — xpubs, and the balances, addresses and history derived from them — is stored **in the clear**. That is what makes a locked wallet still show a balance, which is the property that stops anyone leaving a wallet unlocked for convenience. The cost: **anyone holding the Postgres database, or a backup of it, can read any user's balances and history.** They can never move a coin. Encrypting it per-user was discussed and explicitly deferred — it would cost balance-while-locked, and the decision was not to hinder the extraction for it. Recorded here rather than left to be discovered. ## A hazard that is NOT this plugin's, but which will bite here first Layer 2 of the custody design encrypts the sealed envelope with the platform's `encryptSecret()`. If that key is lost, **the envelope cannot be opened and the seed is gone** — layer 1 is inside layer 2. This is not hypothetical: on 2026-08-19 the alpha server's key had been re-minted, and every stored secret (slskd, gitea, memos) became undecryptable. Those are re-typeable credentials. A seed envelope is not. **Back up the key file before funding a wallet.** ## What moved | Was | Is now | | -------------------------------------------------------- | --------------- | | `src/servers/sidecar/wallet/*` (19 files, incl. `aezeed/`, `backends/`) | `sidecar/*` | | `src/servers/api/wallet/router.ts` | `api/router.ts` | | `src/databases/officer_db/src/wallet/*` (3 files) | `db/*` | | `src/workspaces/officerdev/src/apps/Wallet/*` (35 files, incl. `dialogs/`) | `web/*` | | `src/apps/officer-web/Screens/Dashboard/Wallet/*` | `web/layout.ts` | **The three test files moved with it and still run** — `keys.test.ts`, `bolt11.test.ts`, `chain-source-esplora.test.ts`, 72 tests covering the custody crypto. `bunfig.toml`'s test root was widened on 2026-08-15 for exactly this, after music took `lyrics.test.ts` away and the suite silently shrank by nine. Worth stating because it is the one extraction where losing the tests would have mattered most. ## Not self-contained in one place `config.ts` and `upstream.ts` read the core `service_connections` table for the NBXplorer connection — the same coupling transmission and soulseek have. The wallet's own three tables moved with it. ## Fixtures `wallet` was the canonical `kind: 'admin'` fixture in `registry.test.ts` and its no-selfService fixture. Re-anchored to `user-admin`/`server-admin` and `terminal`. The admin-kind test now carries a note that a plugin's permissions are always `app` — which is precisely why this extraction needed a decision rather than a default.