Files
bitcoin-wallet/web/dialogs/SeedBackupDialog.tsx
officer a4cf939be7 bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision
rather than a mechanical move.

The wallet permission was kind: 'admin' — owner only, not grantable, invisible
to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts
forces it and the manifest has no field for anything else, deliberately, so
that "can a plugin escalate itself" is a question which cannot be asked. So
extraction could not preserve admin, and that was raised as a blocker before
any code moved.

It was the right change anyway, and the objection inverts. Every wallet query
is scoped by user_id, and that id comes from X-Officer-User which the platform
injects from the authenticated session — a caller cannot name someone else's.
So granting this gives an account ITS OWN wallet. Not through the API and not
with root either does it hand over anyone else's, because their seed is sealed
under a passphrase the server never stores. kind: 'admin' was single-user
thinking; grantable is how a second person gets a wallet at all.

Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically,
and the appName drives the route, the API prefix, the table prefix and the
install key together. Tables renamed to bitcoin_wallet_* accordingly — the
prefix guard normalises the hyphen. Free only because no wallet table existed
on any server, which was checked before deciding: db:push treats a rename as a
drop plus a create.

The three custody test files moved with it and still run — 72 tests over
scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened
on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by
nine, so that trap was already closed.

68 palette classes converted, five status vocabularies hoisted into shared.ts.
Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all
read as warning, and the bitcoin and lightning rails no longer differ by hue.

Deferred on purpose: watch-only data (xpubs, balances, history) stays
unencrypted so a locked wallet still shows a balance. Anyone with the database
can therefore SEE any user's balances; they can never move a coin.
2026-08-19 05:18:29 +00:00

95 lines
3.8 KiB
TypeScript

import { useState } from 'react';
import { TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
// The recovery phrase, shown exactly once.
//
// The sidecar returns `mnemonic` on the create response and only when IT generated the seed. There is no
// second chance without the passphrase (settings → export seed), so this modal cannot be dismissed by
// clicking away, pressing Escape, or anything short of ticking the box and pressing the button.
//
// The words arrive as a prop, live in this component's render only, and are never written to React Query,
// localStorage, sessionStorage, the URL, or a clipboard helper. Closing the modal is the only exit and it
// drops the string on the floor. There is deliberately no "copy" button: a seed on the clipboard is a seed
// in every clipboard manager on the machine.
type SeedBackupDialogProps = {
open: boolean;
mnemonic: string;
walletName: string;
onConfirmed: () => void;
};
export const SeedBackupDialog = ({ open, mnemonic, walletName, onConfirmed }: SeedBackupDialogProps) => {
const [acknowledged, setAcknowledged] = useState(false);
const words = mnemonic.trim().split(/\s+/);
const confirm = () => {
setAcknowledged(false);
onConfirmed();
};
return (
<Dialog open={open}>
{/* `[&>button]:hidden` drops the shared X — with no onOpenChange it would be inert anyway, and an
inert close button on the one dialog you must not dismiss is worse than no button. */}
<DialogContent
className="max-w-lg [&>button]:hidden"
onEscapeKeyDown={(ev) => ev.preventDefault()}
onPointerDownOutside={(ev) => ev.preventDefault()}
onInteractOutside={(ev) => ev.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TriangleAlert className="h-5 w-5 text-warning" />
Write down your recovery phrase
</DialogTitle>
<DialogDescription>
These {words.length} words are the only way to recover <span className="font-medium">{walletName}</span> if
this server is lost. Officer cannot show them again without your passphrase.
</DialogDescription>
</DialogHeader>
<ol className="mt-2 grid grid-cols-3 gap-1.5 rounded-lg border border-warning/30 bg-warning/5 p-3 sm:grid-cols-4">
{words.map((word, i) => (
<li key={`${i}-${word}`} className="flex items-baseline gap-1.5 font-mono text-xs">
<span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">{i + 1}</span>
<span className="font-medium">{word}</span>
</li>
))}
</ol>
<p className="text-xs text-muted-foreground">
Write them on paper, in order. Anyone who reads them can spend these coins, so do not photograph them, type
them into another device, or paste them anywhere.
</p>
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
<Checkbox className="mt-0.5" checked={acknowledged} onCheckedChange={(v) => setAcknowledged(v === true)} />
<span>
I have written this phrase down and stored it somewhere safe.
<span className="mt-0.5 block text-xs text-muted-foreground">
It will not be shown again after this dialog closes.
</span>
</span>
</label>
<DialogFooter>
<Button onClick={confirm} disabled={!acknowledged}>
I have written it down
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};