bitcoin-wallet, extracted into plugins/bitcoin-wallet — the platform half

Tenth extraction, and the first where the blocker was a permission rather than
a path. The wallet was kind: 'admin' — owner only, never grantable — and plugin
permissions are always kind: 'app'. mount.ts forces that and the manifest has
no field for anything else, deliberately, so a plugin cannot ask to be
restricted. Extraction therefore could not preserve admin.

Raised before moving any code, because turning "the server owner's bitcoin"
into a row in the grants list beside Photos and Music is the kind of change
nobody notices until someone ticks it.

Made anyway, and the objection inverts: every wallet query is scoped by
user_id, taken from X-Officer-User which the platform injects from the session,
so granting the permission gives an account its OWN wallet rather than access
to anyone else's — including to the owner, whose bypass covers permission
checks and not query scoping. kind: 'admin' was single-user thinking.

Named bitcoin-wallet, not wallet: appName drives route, API prefix, table
prefix and install key together, so the name should say what it is. Tables are
bitcoin_wallet_*; the prefix guard normalises the hyphen. Verified free before
committing to it — no wallet table exists on any server, and db:push would
treat a rename as a drop plus a create.

registry.test.ts re-anchored: `wallet` was its canonical kind: 'admin' fixture
and its no-selfService fixture. Now user-admin/server-admin and terminal, with
a note that a plugin's permissions are always `app` — which is exactly why this
one needed a decision rather than a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 05:18:44 +00:00
co-authored by Claude Opus 5
parent f2c6ebaa4d
commit bbb16d6ed3
81 changed files with 31 additions and 16386 deletions
-1
View File
@@ -47,7 +47,6 @@ export * from './user-data';
export * from './dav';
export * from './notify';
export * from './wallet';
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
// deleted end to end on 2026-08-13 after `task-logger.ts` turned out to have no callers — a full read
-1
View File
@@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections
// export * from './notify/schema'; // push_devices officer-notify
// export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
//
@@ -1,30 +0,0 @@
export {
listWallets,
getWallet,
getActiveWallet,
getWalletSecrets,
getSealedSeed,
createWallet,
updateWallet,
replaceSealedSeed,
setActiveWallet,
deleteWallet,
getWalletLabels,
setWalletLabel,
getFrozenOutpoints,
setUtxoFrozen,
getWalletChainCache,
saveWalletChainCache,
recordWalletChainError,
} from './queries';
export type {
WalletKind,
WalletSummary,
WalletSecrets,
WalletLabel,
CreateWalletParams,
WalletChainCache,
} from './queries';
export type { WalletChainSnapshot } from './schema';
@@ -1,349 +0,0 @@
import type { WalletChainSnapshot } from './schema';
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { walletWallets, walletLabels, walletFrozenUtxos, walletChainCache } from './schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the at-rest layer ('wallet'
// purpose in the secret store) is
// applied and stripped here, so route handlers never touch crypto. See ../crypto.ts, ../schema/wallet.ts.
//
// Note what "plaintext" means for `seedEnvelope`: it is the passphrase-sealed envelope, which is itself
// still ciphertext. This layer only removes the SECOND wrapping. Nothing in this file can read a mnemonic,
// and that is intentional — only servers/sidecar/wallet/keys.ts can, and only with the owner passphrase.
//
// THREE return types, and the projection is what enforces the separation:
// WalletSummary — safe to serialize to the browser. No config, no seed envelope, at all.
// WalletSecrets — decrypted config for the sidecar's own upstream calls. Never returned by a handler.
// SealedSeed — the sealed envelope, for keys.ts to open with the owner passphrase.
// A bare `select()` would leak both ciphertext columns into every list response the moment someone
// forgot to strip them, so no function here uses one.
export type WalletKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
export type WalletSummary = {
id: number;
name: string;
kind: WalletKind;
network: string;
fingerprint: string | null;
xpubs: Record<string, string> | null;
defaultBip: number;
isActive: boolean;
/** Whether this wallet holds a seed at all — i.e. whether unlock/lock apply to it. */
hasSeed: boolean;
createdAt: Date;
};
export type WalletSecrets = { id: number; kind: WalletKind; network: string; config: Record<string, unknown> | null };
const walletCols = {
id: walletWallets.id,
name: walletWallets.name,
kind: walletWallets.kind,
network: walletWallets.network,
fingerprint: walletWallets.fingerprint,
xpubs: walletWallets.xpubs,
defaultBip: walletWallets.defaultBip,
isActive: walletWallets.isActive,
createdAt: walletWallets.createdAt,
seedEnvelope: walletWallets.seedEnvelope,
};
function toSummary(row: {
id: number;
name: string;
kind: string;
network: string;
fingerprint: string | null;
xpubs: Record<string, string> | null;
defaultBip: number;
isActive: boolean;
createdAt: Date;
seedEnvelope: string | null;
}): WalletSummary {
return {
id: row.id,
name: row.name,
kind: row.kind as WalletKind,
network: row.network,
fingerprint: row.fingerprint,
xpubs: row.xpubs,
defaultBip: row.defaultBip,
isActive: row.isActive,
hasSeed: row.seedEnvelope !== null,
createdAt: row.createdAt,
};
}
/** Every wallet the owner has registered, active first then newest. Never includes secrets. */
export async function listWallets(userId: number): Promise<WalletSummary[]> {
const rows = await db
.select(walletCols)
.from(walletWallets)
.where(eq(walletWallets.userId, userId))
.orderBy(desc(walletWallets.isActive), desc(walletWallets.createdAt));
return rows.map(toSummary);
}
export async function getWallet(userId: number, id: number): Promise<WalletSummary | null> {
const [row] = await db
.select(walletCols)
.from(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
return row ? toSummary(row) : null;
}
export async function getActiveWallet(userId: number): Promise<WalletSummary | null> {
const [row] = await db
.select(walletCols)
.from(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
return row ? toSummary(row) : null;
}
/** Decrypted backend connection config, for the sidecar's upstream calls. Never leaves the sidecar. */
export async function getWalletSecrets(userId: number, id: number): Promise<WalletSecrets | null> {
const [row] = await db
.select({
id: walletWallets.id,
kind: walletWallets.kind,
network: walletWallets.network,
config: walletWallets.config,
})
.from(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
if (!row) return null;
return {
id: row.id,
kind: row.kind as WalletKind,
network: row.network,
config: row.config ? (JSON.parse(decryptSecret('wallet', row.config)) as Record<string, unknown>) : null,
};
}
/**
* The passphrase-sealed seed envelope, still sealed. Returns null for wallets that hold no seed.
* The ONLY caller should be the sidecar's unlock path.
*/
export async function getSealedSeed(userId: number, id: number): Promise<string | null> {
const [row] = await db
.select({ seedEnvelope: walletWallets.seedEnvelope })
.from(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
if (!row?.seedEnvelope) return null;
return decryptSecret('wallet', row.seedEnvelope);
}
export type CreateWalletParams = {
userId: number;
name: string;
kind: WalletKind;
network: string;
/** Plaintext; encrypted here. */
config?: Record<string, unknown> | null;
/** The already-sealed envelope as JSON; encrypted again here. */
sealedSeed?: string | null;
fingerprint?: string | null;
xpubs?: Record<string, string> | null;
defaultBip?: number;
makeActive?: boolean;
};
export async function createWallet(params: CreateWalletParams): Promise<WalletSummary> {
return db.transaction(async (tx) => {
if (params.makeActive) {
await tx
.update(walletWallets)
.set({ isActive: false })
.where(and(eq(walletWallets.userId, params.userId), eq(walletWallets.isActive, true)));
}
const [row] = await tx
.insert(walletWallets)
.values({
userId: params.userId,
name: params.name,
kind: params.kind,
network: params.network,
config: params.config ? encryptSecret('wallet', JSON.stringify(params.config)) : null,
seedEnvelope: params.sealedSeed ? encryptSecret('wallet', params.sealedSeed) : null,
fingerprint: params.fingerprint ?? null,
xpubs: params.xpubs ?? null,
defaultBip: params.defaultBip ?? 84,
isActive: params.makeActive ?? false,
})
.returning(walletCols);
return toSummary(row!);
});
}
/**
* Mutable wallet metadata. CANNOT TOUCH THE SEED — see replaceSealedSeed below for why that is a
* separate function rather than one more optional field here.
*/
export async function updateWallet(
userId: number,
id: number,
patch: { name?: string; config?: Record<string, unknown>; defaultBip?: number },
): Promise<WalletSummary | null> {
const set: Record<string, unknown> = { updatedAt: new Date() };
if (patch.name !== undefined) set.name = patch.name;
if (patch.defaultBip !== undefined) set.defaultBip = patch.defaultBip;
if (patch.config !== undefined) set.config = encryptSecret('wallet', JSON.stringify(patch.config));
const [row] = await db
.update(walletWallets)
.set(set)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
.returning(walletCols);
return row ? toSummary(row) : null;
}
/**
* Overwrite the encrypted seed. The ONLY caller is the passphrase change, which reseals the same words
* under a new passphrase and has already proved knowledge of the old one.
*
* It is a function of its own, rather than a fourth field on updateWallet, because it was one: the PATCH
* route typed its request body as `{name, defaultBip, config}` and passed the parsed object straight
* through, and a TypeScript annotation strips nothing at runtime. Any authenticated caller could send a
* `sealedSeed` key and replace the seed — no passphrase, no unlock, no confirmation — and since
* encryptSecret happily encrypts nonsense, the damage only surfaced at the next unlock attempt. Keeping
* the two apart means the next field added to that body cannot re-open it.
*/
export async function replaceSealedSeed(userId: number, id: number, sealedSeed: string): Promise<void> {
await db
.update(walletWallets)
.set({ seedEnvelope: encryptSecret('wallet', sealedSeed), updatedAt: new Date() })
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
}
/** Exactly one active wallet per owner. Cleared and set in one transaction; the partial unique index is the backstop. */
export async function setActiveWallet(userId: number, id: number): Promise<void> {
await db.transaction(async (tx) => {
await tx
.update(walletWallets)
.set({ isActive: false })
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
await tx
.update(walletWallets)
.set({ isActive: true, updatedAt: new Date() })
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
});
}
export async function deleteWallet(userId: number, id: number): Promise<boolean> {
const rows = await db
.delete(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
.returning({ id: walletWallets.id });
return rows.length > 0;
}
// ── labels ───────────────────────────────────────────────────────────────────────────────────────
export type WalletLabel = { kind: 'address' | 'tx'; ref: string; label: string };
export async function getWalletLabels(walletId: number): Promise<WalletLabel[]> {
const rows = await db
.select({ kind: walletLabels.kind, ref: walletLabels.ref, label: walletLabels.label })
.from(walletLabels)
.where(eq(walletLabels.walletId, walletId));
return rows.map((r) => ({ kind: r.kind as 'address' | 'tx', ref: r.ref, label: r.label }));
}
export async function setWalletLabel(
walletId: number,
kind: 'address' | 'tx',
ref: string,
label: string,
): Promise<void> {
// An empty label is a delete — the UI clears a field rather than pressing a separate button.
if (!label.trim()) {
await db
.delete(walletLabels)
.where(and(eq(walletLabels.walletId, walletId), eq(walletLabels.kind, kind), eq(walletLabels.ref, ref)));
return;
}
await db
.insert(walletLabels)
.values({ walletId, kind, ref, label })
.onConflictDoUpdate({ target: [walletLabels.walletId, walletLabels.kind, walletLabels.ref], set: { label } });
}
// ── frozen UTXOs ─────────────────────────────────────────────────────────────────────────────────
export async function getFrozenOutpoints(walletId: number): Promise<string[]> {
const rows = await db
.select({ outpoint: walletFrozenUtxos.outpoint })
.from(walletFrozenUtxos)
.where(eq(walletFrozenUtxos.walletId, walletId));
return rows.map((r) => r.outpoint);
}
export async function setUtxoFrozen(
walletId: number,
outpoint: string,
frozen: boolean,
reason?: string,
): Promise<void> {
if (!frozen) {
await db
.delete(walletFrozenUtxos)
.where(and(eq(walletFrozenUtxos.walletId, walletId), eq(walletFrozenUtxos.outpoint, outpoint)));
return;
}
await db
.insert(walletFrozenUtxos)
.values({ walletId, outpoint, reason: reason ?? null })
.onConflictDoNothing();
}
// ── chain cache ──────────────────────────────────────────────────────────────────────────────────
//
// Unlike everything above, none of this is authoritative — see the comment on the table. These three
// functions are deliberately total: a cache read or write must never be able to fail a wallet request,
// so the sidecar treats every one of them as best-effort.
export type WalletChainCache = {
snapshot: WalletChainSnapshot | null;
syncedAt: Date | null;
lastError: string | null;
lastErrorAt: Date | null;
};
export async function getWalletChainCache(walletId: number): Promise<WalletChainCache | null> {
const [row] = await db
.select({
snapshot: walletChainCache.snapshot,
syncedAt: walletChainCache.syncedAt,
lastError: walletChainCache.lastError,
lastErrorAt: walletChainCache.lastErrorAt,
})
.from(walletChainCache)
.where(eq(walletChainCache.walletId, walletId));
return row ?? null;
}
/** A successful refresh. Clears the error columns — the upstream just answered, so nothing is wrong now. */
export async function saveWalletChainCache(walletId: number, snapshot: WalletChainSnapshot): Promise<void> {
const syncedAt = new Date();
await db
.insert(walletChainCache)
.values({ walletId, snapshot, syncedAt, lastError: null, lastErrorAt: null })
.onConflictDoUpdate({
target: walletChainCache.walletId,
set: { snapshot, syncedAt, lastError: null, lastErrorAt: null },
});
}
/** A failed refresh. Touches only the error columns, so the last good snapshot and its age survive. */
export async function recordWalletChainError(walletId: number, message: string): Promise<void> {
const lastErrorAt = new Date();
// Bounded because this is an upstream message verbatim, and an HTML error page from a misconfigured
// proxy would otherwise land whole in the column.
const lastError = message.slice(0, 500);
await db
.insert(walletChainCache)
.values({ walletId, snapshot: null, syncedAt: null, lastError, lastErrorAt })
.onConflictDoUpdate({ target: walletChainCache.walletId, set: { lastError, lastErrorAt } });
}
@@ -1,175 +0,0 @@
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from '../auth/schema';
// Bitcoin wallets for the officer-wallet sidecar. The owner registers one or more wallets — either a
// self-custodial on-chain wallet whose seed lives here, or a connection to a node (LND / Core Lightning /
// LNDHub / NWC) exactly as Zeus models them — and switches between them.
//
// TWO COLUMNS HOLD SPENDING AUTHORITY AND THEY ARE PROTECTED DIFFERENTLY. This asymmetry is deliberate:
//
// `config` — node credentials (macaroon, rune, LNDHub password, NWC URI). Encrypted at rest with
// the 'wallet' store key via ../crypto.ts, the way offscale_servers.api_key uses its
// own. It CANNOT be
// passphrase-protected: background balance polling needs it without the owner present.
//
// `seed_envelope` — a BIP39 mnemonic that is ALREADY sealed under an owner passphrase by the sidecar
// (see servers/sidecar/wallet/keys.ts) before it ever arrives here, and is then
// encrypted AGAIN with the 'wallet' store key on the way into this column. Two independent
// secrets, neither sufficient alone. A database dump does not spend; a leaked .env
// does not spend.
//
// `xpubs` and `fingerprint` are stored in the CLEAR, on purpose. They are what lets the wallet show
// balances, history and fresh receive addresses while locked — the watch-only-when-locked property. An
// xpub leak costs privacy (an observer can enumerate the wallet's addresses), never funds.
//
// Every table is `wallet_`-prefixed and this file holds nothing else, so it can move into
// src/servers/sidecar/wallet/ wholesale when sidecars own their schema. Only officer-wallet reads these.
export const walletWallets = pgTable(
'wallet_wallets',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
// BackendKind: 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc'. TS-only enum, no DB CHECK —
// matches how pipeline_jobs.mode is handled elsewhere in this schema.
kind: text('kind').notNull(),
// BitcoinNetwork: 'bitcoin' | 'testnet' | 'signet' | 'regtest'.
network: text('network').notNull().default('bitcoin'),
// Backend connection config as JSON, encrypted. Null for a pure on-chain wallet, which connects to
// nothing but the Esplora endpoint the sidecar is configured with.
config: text('config'),
// The passphrase-sealed SeedEnvelope, encrypted again. Null for every remote-node wallet — those
// hold no seed, the node does.
seedEnvelope: text('seed_envelope'),
// BIP32 master fingerprint (8 hex chars), for PSBT construction and hardware-wallet pairing.
fingerprint: text('fingerprint'),
// { "44": "xpub…", "49": "ypub…", "84": "zpub…", "86": "xpub…" } — plaintext, see header.
xpubs: jsonb('xpubs').$type<Record<string, string>>(),
// Which derivation standard new receive addresses use. 84 (native segwit) is the default.
defaultBip: integer('default_bip').notNull().default(84),
isActive: boolean('is_active').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('uq_wallet_wallets_user_name').on(t.userId, t.name),
// At most one active wallet per owner, enforced by the DB rather than convention — a partial unique
// index over active rows only, mirroring uq_offscale_servers_one_active.
uniqueIndex('uq_wallet_wallets_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);
// Owner-assigned labels for addresses and transactions. Zeus keeps these client-side; here they belong to
// the server so every client sees the same annotations.
export const walletLabels = pgTable(
'wallet_labels',
{
id: serial('id').primaryKey(),
walletId: integer('wallet_id')
.notNull()
.references(() => walletWallets.id, { onDelete: 'cascade' }),
// 'address' | 'tx'
kind: text('kind').notNull(),
// The address string or txid being labelled.
ref: text('ref').notNull(),
label: text('label').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex('uq_wallet_labels_wallet_kind_ref').on(t.walletId, t.kind, t.ref)],
);
// Frozen UTXOs, excluded from automatic coin selection. Separate from labels because this one affects
// what the wallet will actually spend — losing a label is cosmetic, losing a freeze spends a coin the
// owner meant to keep back.
//
// NOTE the contrast with wallet_chain_cache below: this table is POLICY (the owner's decision, only
// they can restate it) and that one is OBSERVATION (the chain's, refetchable at any time). That is why
// only one of them can be safely truncated.
export const walletFrozenUtxos = pgTable(
'wallet_frozen_utxos',
{
id: serial('id').primaryKey(),
walletId: integer('wallet_id')
.notNull()
.references(() => walletWallets.id, { onDelete: 'cascade' }),
// `txid:vout`
outpoint: text('outpoint').notNull(),
reason: text('reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex('uq_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)],
);
/**
* The last thing the chain told us about an on-chain wallet, so a wallet screen paints real numbers
* immediately instead of nothing while a gap-limit scan runs.
*
* Every field is DERIVED and rebuildable — it is recomputed from the account xpubs above on each
* successful refresh, so this table holds no secret and nothing authoritative. Truncating it costs one
* slow load and nothing else.
*
* Only the chain's own facts are stored. Addresses, scripts and pubkeys are NOT: they are pure EC
* derivation from the xpub, cheaper to recompute on load than to persist, and re-deriving them means a
* restored snapshot can never disagree with the wallet's actual keys.
*/
export type WalletChainSnapshot = {
/** Bumped when the shape below changes; a snapshot at any other version is discarded, not migrated. */
v: 1;
tipHeight: number;
/** One entry per address the scan visited, keyed by its derivation coordinates. */
addresses: {
/** AddressType: 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh'. */
type: string;
/** BIP44 chain index: 0 receive, 1 change. */
chain: number;
index: number;
confirmed: number;
unconfirmed: number;
txCount: number;
}[];
utxos: {
txid: string;
vout: number;
value: number;
type: string;
chain: number;
index: number;
/** Null while unconfirmed. Confirmations are recomputed against tipHeight, never stored. */
blockHeight: number | null;
}[];
/** The OnchainTx shape from servers/sidecar/wallet/types.ts, restated here so this package imports nothing. */
transactions: {
txid: string;
amount: number;
feeSats: number | null;
blockHeight: number | null;
timestamp: number | null;
confirmations: number;
label: string | null;
destAddresses: string[];
rawHex: string | null;
}[];
};
export const walletChainCache = pgTable('wallet_chain_cache', {
// One row per wallet, so a refresh is a single atomic upsert with no partially-written window.
walletId: integer('wallet_id')
.primaryKey()
.references(() => walletWallets.id, { onDelete: 'cascade' }),
// Nullable: a wallet whose very first refresh failed has an error to report and no data to report it
// against, and that row still has to exist for the UI to say why the screen is empty.
snapshot: jsonb('snapshot').$type<WalletChainSnapshot>(),
/** When the chain was last read successfully — the age the UI shows. Not when the row was written. */
syncedAt: timestamp('synced_at', { withTimezone: true }),
// Kept ALONGSIDE the last good snapshot rather than replacing it. A failure that wiped the data would
// reproduce the exact bug this table exists to fix: an unreachable upstream rendering as a zero balance
// instead of as a stale one.
lastError: text('last_error'),
lastErrorAt: timestamp('last_error_at', { withTimezone: true }),
});