aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
* docker: ghcr.io/coinos/bun, oven/bun with the distrusted emSign G3 root ↵HEADmasterAdam Soltys19 hours2-21/+31
| | | | | | | | | swapped out bun-distrust.ts now carries the root's DER itself instead of finding it through tls.getCACertificates, so it works on any bun image. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* base: bun-distrust leaves the binary alone when there's nothing to replaceAdam Soltys19 hours1-2/+4
| | | | Co-Authored-By: Claude Opus 5.5 <[email protected]>
* base: take emSign ECC Root CA - G3 out of bun's built-in root storeAdam Soltys19 hours2-0/+44
| | | | | | | | | | Bun compiles Mozilla's roots into its binary and NODE_USE_SYSTEM_CA only adds to them, so the fixed Debian ca-certificates doesn't reach fetch/tls. docker/bun-distrust.ts overwrites the root's DER in place with a same-length self-signed placeholder (key discarded) at image build time. Drop it once a bun release removes the root itself. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* base: bun 1.3.11 → 1.4.3Adam Soltys19 hours1-1/+1
| | | | Co-Authored-By: Claude Opus 5.5 <[email protected]>
* base: pin bun to 1.3.11 so rebuilding the image (fresh ca-certificates for ↵Adam Soltys19 hours1-1/+3
| | | | | | the emSign CA compromise) doesn't also upgrade bun Co-Authored-By: Claude Opus 5.5 <[email protected]>
* users: let a password unlock the encrypted nostr key for any sessionAdam Soltys8 days2-0/+34
| | | | | | | | | | /me only reports hasnsec now, so sessions that didn't start with a password login had no way to reveal or sign with their key. POST /nsec returns it after re-checking the password, under the login per-IP failure limit. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UjHzoBmW1iPpEe5qC2LDYB
* login: drop the unused per-username failure counterAdam Soltys9 days1-7/+0
| | | | | | | | | The counter was incremented on every failed login and read but never acted on; the per-IP limits do the throttling. Its keys never expired and had grown to most of the keyspace. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UjHzoBmW1iPpEe5qC2LDYB
* bolt12: put the requested expiry and single-use on the offer itselfAdam Soltys13 days1-0/+9
| | | | | | | | The expiry was only stored on our invoice record, so CLN kept issuing invoices for the offer after it had expired. Pass it as absolute_expiry, and accept single_use to cap an offer at one payment. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* users: keep the encrypted nostr key and 2FA seed away from bearer tokensAdam Soltys14 days2-4/+15
| | | | | | | | /me and user emits return a hasnsec flag instead of the encrypted key, which is now only sent with a password login or registration. /otpsecret refuses once 2FA is enabled, and disabling 2FA rotates the seed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* analytics-geo: read rotated request log chunksAdam Soltys14 days1-6/+26
| | | | Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Remove response loggerAdam Soltys14 days1-19/+0
| | | | Co-Authored-By: Claude Opus 5.5 <[email protected]>
* me: stop reporting archived balance as locked for frozen accountsAdam Soltys14 days1-16/+1
| | | | Co-Authored-By: Claude Opus 5.5 <[email protected]>
* lightningd: bump to v26.06.9Adam Soltys2026-10-071-1/+1
| | | | Co-Authored-By: Claude Opus 5.5 <[email protected]>
* email: escape support form HTML and enforce recaptcha v3 scoreAdam Soltys2026-10-061-37/+56
| | | | | | | | Escape the message body, stop accepting a client-supplied subject, strip control characters from header values, require the expected action and a minimum score from siteverify, and drop the adminpass captcha bypass. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* payments: lowercase payreq before looking up our own invoicesAdam Soltys2026-10-062-1/+12
| | | | | | | | | An uppercase bolt11 for a coinos invoice missed the getInvoice lookup, so it was paid over lightning to our own node instead of internally. The listener then saw the sender's payment pointer for that bolt11 and skipped crediting the recipient. Normalize in POST /payments and nwc pay_invoice. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* invoices: a supplied account must belong to the invoice's userAdam Soltys2026-10-061-1/+6
| | | | | | | | | | generate() took any existing account id as the credit target, so an invoice could carry one user's name while paying into someone else's account. A supplied aid now has to be in the invoice user's own account list; otherwise the invoice credits that user's main account. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KCxLg8s1g8yBwjRwPN4uJR
* Evicted accounts can't receive or log in; limit errors say what's happeningAdam Soltys2026-10-043-3/+18
| | | | | | | | | | | An evicted account no longer gets lightning-address invoices (stops a scam page's address from minting new ones) or a login token. A send over the server limit now says withdrawals are temporarily limited instead of a generic "Problem sending payment" that read as a frozen account. (Already live since the 2026-10-01 restart.) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_011bvzKaZosXGHkigcGcSzAR
* v3 bridge: a migrated name's legacy receives are paid on to its v3 walletAdam Soltys2026-10-042-2/+147
| | | | | | | | | | | | | | | | | | A user who moved their name to coinos v3 keeps their legacy account, and old BOLT12 offers, bolt11 invoices and bitcoin/liquid deposit addresses keep paying into it. Each confirmed receive of those kinds is now forwarded as it lands to the name's v3 lightning address (registrar invoice on the ASP node; the v3 forwarder delivers to the Ark mailbox even with the wallet closed). Only new receives move; existing balances stay. Routing cost only: the platform fee is waived (debit exempt), and the direct channels to the ASP node quote 0. Proof required: the registrar holds the name for the legacy account's own key, or for the wallet its session named in a migration grant (/name granted flag). Autowithdraw stands down for forwarded receives; failures retry with backoff (v3fwd:retry, one process at a time) and otherwise stay put. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_011bvzKaZosXGHkigcGcSzAR
* payments: refuse on-chain sends to blocked addresses before debitingAdam Soltys2026-09-291-0/+19
| | | | | | | | | debit() checks blocked_addresses against the payment hash, which for an on-chain send is the txid, so a blocked destination address was never matched. Check each output address of the built transaction in sendOnchain and fail before any debit. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* lnurl: budget unauthenticated invoice generation per recipientAdam Soltys2026-09-291-5/+34
| | | | | | | | | | | | | GET /pay/:user/:amount and the lnurl callback create a CLN invoice and an entry in the recipient's invoice list for anyone holding the link, which a scanner on rotating IPs can do without tripping per-IP limits. Cap fresh invoices at 120 per recipient per hour (reused invoices don't count). Also fail cleanly for unknown usernames and skip invoice ids whose records no longer exist, instead of throwing on the null and breaking the pay page. Throttle the resulting lnurlp warnings per username. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* logging: throttle log lines that clients can trigger at willAdam Soltys2026-09-294-11/+57
| | | | | | | | | | | | Evicted-JWT replays, blocked sends from frozen or limited accounts and failed payment retries each logged one line per request, so a looping client could flood the log, and an evicted token replayed across a proxy pool made atk-autoban.sh block every IP it saw. Add quiet()/suppressed() in lib/logging.ts: the first occurrence per key logs as before, repeats within the window are counted and reported on the next line that gets through. The evicted-auth summary line is worded so the auto-banner ignores it. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* invoices: don't let a supplied id overwrite another user's invoiceAdam Soltys2026-09-292-0/+21
| | | | | | | | | | | | generate() writes invoice:<id> wholesale with the caller's uid, and the old bolt11 pointer keeps resolving to that id, so passing someone else's invoice id re-pointed their invoice at the caller's account. Invoice ids aren't secret (LUD-21 verify URLs, invoice pages, receipts), so refuse an existing id that belongs to a different user, both in generate() and in POST /invoice for anonymous pay-page callers. Both fail with "invoice not found" so ids can't be probed for existence. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* ecash: drop anonymous token storage in favour of a stateless status lookupAdam Soltys2026-09-272-7/+9
| | | | | | | | | POST /cash stored any submitted string under a fresh id with no auth, no validation and no expiry. Replace it with POST /ecash/status, which checks a token the client already holds and returns its total, spent amount and mint. GET /cash/:id/:version stays so previously shared ids keep resolving. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* upload: decode images in a throwaway child process and tighten input checksAdam Soltys2026-09-263-11/+127
| | | | | | | | | | | | | | | Malformed HEIF uploads were corrupting the heap inside libvips/libheif and taking the whole app down with them, since sharp ran in-process. Move the decode into a per-upload child (lib/image-worker.ts, spawned by lib/image.ts with a hard timeout) so a decoder crash fails one request instead of the server, and never load sharp in the main process. Also: only accept jpeg/png/webp/gif as sniffed by file-type instead of any image/* subtype, decode with failOnError and a pixel cap, refuse uploads from blacklisted accounts (a freeze previously only blocked sends), and rate-limit uploads per account. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* app: don't trust client IP headers on onion requestsAdam Soltys2026-09-251-0/+30
| | | | | | | | The tor container connects directly, bypassing Cloudflare, so the client could set cf-connecting-ip, x-forwarded-for and rate-limit-by to anything. Pin those requests to the tor container's address before the rate limiter runs. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* safe-fetch: decode IPv6 hosts before checking for private addressesAdam Soltys2026-09-251-10/+46
| | | | | | | | | URL parsing serializes IPv6 hosts in hex, so an IPv4-mapped address never matched the dotted-quad pattern and passed as public. Expand the address and judge every form that embeds an IPv4 address by that address, and block the remaining non-public IPv6 ranges. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* lightning: don't wedge the listener on an already-credited preimageAdam Soltys2026-09-232-2/+15
| | | | | | | | Skip settlements whose preimage was already credited instead of retrying until the process exits, and reject previously used preimages when they are queued. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* lightningd: upgrade Core Lightning to v26.06.8Adam Soltys2026-09-221-1/+1
| | | | | | | | Point release with security fixes reported through responsible disclosure; upstream withheld the CVE details and some tests, and recommends upgrading as soon as practical. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* parse: cache node aliases as a small map with a working timestampAdam Soltys2026-09-211-9/+13
| | | | | | | | | | The alias cache stored the full listnodes output as an array and hung its `last` stamp on that array, which JSON.stringify drops, and the refresh condition was inverted besides. Net effect: every lightning send page view refetched all ~16k nodes and rewrote ~10MB to the db. Keep only the id→alias map with a real timestamp and refresh it hourly. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* lightning: log quote outcomesAdam Soltys2026-09-211-0/+2
| | | | | | | | The quote endpoint only logged failures, so a "send everything" that went wrong somewhere after the quote left nothing to correlate against. Log the solved amount and fees for max quotes and the fee for plain ones. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* funds: close deposits, withdrawals and authorizationsAdam Soltys2026-09-211-17/+16
| | | | | | | | | | | | | | | | The fund mechanism is retired. Deposits (POST /payments with `fund`), withdrawals (POST /take) and pre-authorizations (POST /authorize) now refuse unconditionally instead of consulting the `fund:disabled` flag, so the door cannot be reopened by clearing a key. The deposit path never had a kill-switch of its own, so with the flag set users could still pay into funds they were unable to withdraw from. Read paths are left intact: GET /fund/:id and the manager endpoints still serve existing funds so balances stay visible and can be settled out of band. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* users: reserve service names on registration and renameAdam Soltys2026-09-213-4/+35
| | | | | | | | | | | | A username is also a lightning address, so a name that reads as the service collects payments meant for it. "mint" was registered days after the ecash redesign freed it and received payments addressed to [email protected]; the reserved list held only "ecash", and was duplicated in two places. One shared list now, checked on both paths, covering the service-sounding names an account should never be able to take. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* mintdemux: melt the new half of a mixed selection instead of refusingAdam Soltys2026-09-211-7/+54
| | | | | | | | | | | | | | | | | A wallet holding leftovers of both eras selects proofs from each, and the two mints cannot co-sign one melt, so such wallets were stuck for good behind the "melt them separately" error. Drop the old inputs and melt the new ones. The old proofs stay unspent at the old mint while the wallet treats them as spent, so each one is recorded with its keyset, amount, secret hash and whether it carried DLEQ; they were only redeemable through the recovery path anyway. Also stop the routing tables from going empty. The hourly refresh is exempt from the mints' per-ip limiter, so it now seeds the last-good keyset lists the client-facing /v1/keysets falls back on, and a start that races the mints coming up retries every 15s rather than waiting out the hour -- an empty old list makes every old-ecash melt look new and routes it to the wrong mint. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* ecash: identify our own tokens by keyset, not by the mint urlAdam Soltys2026-09-211-20/+28
| | | | | | | | | | | | | | The mint url in a cashu token is a label the sending wallet writes in, and tokens of ours turn up carrying stray ports and paths. Fetching it to compare pubkeys therefore rejected perfectly good tokens, and meant every /cash entry point made an outbound request to a user-supplied address. Keyset ids are derived from the mint's public keys, so a token whose proofs are all on our keysets is ours whatever url it names; a foreign one still fails, and now with a clear message instead of cashu-ts's short-id mapping error. A miss refreshes the cached keyset list once, in case of a rotation. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* docker: build nutshell on the coinos base imageAdam Soltys2026-09-211-0/+57
| | | | | | | | | | | | Multi-stage build: poetry installs the mint into a python:3.12-slim-trixie stage, then /usr/local and /app are copied onto ghcr.io/coinos/base, the same way the lightningd image is assembled. Source is cloned from the coinos nutshell fork at NUTSHELL_REF, and the image runs `mint` directly under tini instead of `poetry run mint`. Python 3.12 rather than trixie's 3.13 because coincurve ships no cp313 wheel. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* lightning: quote the routing fee up front and add a max-send quote endpointAdam Soltys2026-09-214-8/+482
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Users trying to empty their balance over lightning had to guess a maxfee that covered both an unknown routing fee and the platform fee, and the 0.5% floor we imposed on that guess made an exact "send everything" impossible. xpay routes with askrene's getroutes, so ask askrene the same question before we debit and the fee is known exactly. lib/lnquote.ts mirrors what xpay does in plugins/xpay/xpay.c (v26.06): a throwaway private layer carrying the invoice's route hints (bolt11) or blinded paths via xpay's fake destination node (bolt12), then getroutes over the same layer stack in the same order. The fee is the sum of what leaves our node minus what the destination receives, rounded up to a whole sat since that is what xpay's maxfee reserves. POST /lightning/quote returns the routing fee, the platform fee exactly as debit() charges it (free-tier credit, ln:nofree rate, none on sub-accounts) and the total. With `max` it iterates quote / subtract fees / requote so amount + both fees lands exactly on the balance; an offer gets a probe invoice then the real one for the solved amount, and an LNURL caller can pass `ceiling` and a probe invoice the same way. sendLightning now quotes for every send. With no explicit fee the quote becomes the maxfee; an explicit fee below the quote is rejected before any debit instead of failing inside xpay after one; the old 0.5% floor on explicit fees is gone. If the quote itself fails we fall back to the heuristic and let xpay try. finalize rounds the settled fee up to the whole sat that was quoted and reserved; rounding a 301 msat fee to 0 refunded a sat that the quote had already accounted for and stranded it in the account. The prefer-kappa layer is only passed to xpay when it exists on the node; getroutes rejects unknown layers outright, which broke every regtest send. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* lnurl: expire payRequest callback ids after seven daysAdam Soltys2026-09-162-5/+10
| | | | | | | | | A callback id is read once, minutes after the metadata request, and never again, but the pointer keys were written without an expiry and had grown to roughly 45% of all keys in the main database. Let the set helper take a TTL and apply one to both lnurl pointer keys. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* mintdemux: forward the client's rate-limit key on keyset fetchesAdam Soltys2026-09-161-4/+25
| | | | | | | | | | Keyset fetches counted against the demux's own socket IP at both mints, so the shared per-IP bucket tripped under load and wallets received an empty keyset list. Forward the client's key, serve each mint's last-good list when its upstream fails, and mark the demux's own hourly refresh as loopback so the mints' limiter exempts it. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* ecash: redeem tokens by melting to the user's own invoiceAdam Soltys2026-09-167-291/+170
| | | | | | | | | | | Redeeming ecash is now a plain NUT-05 melt into a Lightning invoice owned by the redeeming user, credited by the regular lightning listener. Drop the house proof pool and its lock, the /mint and /melt routes, and the special-casing of a mint account in NWC and payment routing. Tokens are decoded against the mint's keyset list so short v1 ids resolve, and an empty keyset list is never cached. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* printer: publish receipts at QoS 1 and clean LNURL metadata memosAdam Soltys2026-09-142-1/+19
| | | | | | | | | | | | Receipts were published at QoS 0, so a printer that was briefly offline (WiFi blip, keepalive timeout) never received them. QoS 1 lets the broker queue them for printers that hold a persistent session. LNURL-pay invoices carry the raw metadata array as their memo when the payer leaves no comment; unwrap the text/plain entry before printing. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_011cCouc5ASRXHDp5esLciEK
* ecash: make claims work against the v1 keyset mintAdam Soltys2026-09-142-25/+67
| | | | | | | | | | | | | | | cashu-ts 2.9.0 only derives v0 keyset ids, so CashuWallet rejected the mint's current keyset and receive() could never run. Build the wallet from keys preloaded off our own mint instead of verifying, decode tokens with the keysets (2.9.0 shortens v1 ids in the tokens it encodes, and won't expand them otherwise), and treat an unreadable house-pool value as empty rather than throwing — the pool key had been found holding a bare integer, which surfaced as "n.startsWith is not a function" on every redeem. The pool write is awaited so a dropped set can't strand freshly swapped proofs. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_0166Ci2XdCair1B9ych7w8dq
* security: drop the admin endpoint surface and land pending hardeningAdam Soltys2026-09-1318-100/+280
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Removes the admin-gated endpoints and the account flags they set, plus a batch of hardening that had accumulated uncommitted in the tree. These were deployed and verified together, so they land as one change. admin surface: - drop POST /hidepay, POST /unlimit and GET /users, their handlers, and the now-unused admin preValidation export - `unlimited` was only ever written, never read; `hidepay` only appeared in output projections. Both flags go, along with their whitelist, public-fields and type entries. request log: - redactBody now recurses. It only masked top-level keys, so a body that nests credentials one level down wrote them to the log in plaintext, and pin fields were not on the list at all. payments: - sendOnchain/build gate `aid` through requireAccount, so a caller can only spend from an account it owns - sendOnchain signs only a tx we composed, matched against a short-lived per-account build receipt - raise xpay retry_for to 60s; route-finding preferred a drained zero-fee hop and burned the whole budget on it. check()'s reversal window keeps its 2x margin. ecash / invoices / webhooks: - resolve a token's mint through the IP-pinning fetcher instead of plain fetch, closing an unauthenticated SSRF path into the internal network - reject non-positive amounts before the balance write - /sign requires the caller to own the address, rather than signing under any address the hot wallet controls - webhook delivery no longer disables TLS verification; the body carries the merchant's shared secret registration: - refuse a username the v3 registrar holds unless the caller's own nostr key is the holder (lib/names.ts) liquid: - honour the deposit kill switch at invoice generation and at credit elements image: - pin 23.3.4 and verify the release tarball checksum Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_0112TGi9GGNWqji1XQ5jZRzc
* ln listener: skip a settlement whose account is gone before creditingAdam Soltys2026-09-051-0/+9
| | | | | | | | | | credit() now returns without writing for a missing user, but the listener's persistence check after credit() then threw and kept the cursor stuck. Check the account up front and skip like an unknown invoice, recording the preimage in `missing`. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y
* credit: skip settlements for a deleted account instead of throwingAdam Soltys2026-09-051-0/+10
| | | | | | | | | | | | getUser(inv.uid) returns nothing when the account was deleted but its invoice or bolt12 offer still receives (OCEAN payouts to a deleted user's offer). The destructure on the next line threw inside the lightning listener, which never advanced pay_index and crash-looped the app every 50s, blocking all incoming lightning for two hours. Record the preimage in `missing` and return, as an unknown invoice already does. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y
* flash: build each config image in its own temp dirAdam Soltys2026-09-041-6/+16
| | | | | | | | | | The handler wrote every request's config.txt and littlefs.img to the same paths, so two overlapping requests could hand one user the other's wifi key and API token. Use a per-request mkdtemp dir, read the image into memory, and remove the dir when done. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y
* sockets: accept read-only tokens on login, deliver payment events onlyAdam Soltys2026-09-041-1/+12
| | | | | | | | | | | | POS terminals log into the websocket with the "<uid>-ro" token that the web flasher pre-fills. track() looked that id up as a user, failed, and closed the socket a second later, dropping the invoice subscription, so devices were listening about one second in six. Strip the suffix the way lib/auth.ts does for HTTP, flag the socket read-only, and forward only "payment" events to it. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_014gVYGi29FjTeLdNTiutkZx
* meltguard: guard /v1/xpay and parse form-encoded melt bodiesAdam Soltys2026-09-041-8/+38
| | | | | | | | | | | | nutshell >= 0.20.3.1 melts via POST /v1/xpay (form field invstring), while older CLNRest wallets post /v1/pay form-encoded under `invoice` or `bolt11`. The guard only parsed JSON `bolt11`, so form bodies never yielded an amount and were rejected, and an xpay backend would have bypassed the caps entirely. Accept both endpoints and all three field names, forward as form on the same endpoint preserving fee params. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_014uxZLvYS4gH9UqUX2Ey8TV
* payments: finalize settled keysends in sendKeysendAdam Soltys2026-09-041-1/+54
| | | | | | | | | | | | | | keysend blocks until it settles, so a successful return already carries the preimage. Previously only the caller was expected to confirm the record, and the NWC pay_keysend path never did, leaving successful keysends stuck pending forever (check() can't rescue them since the label isn't a bolt11). Add finalizeKeysend(): flip pending->confirmed, derive the real fee from amount_sent_msat, refund the unused fee reserve, and refuse to re-create a record a concurrent reverse() already removed. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_014uxZLvYS4gH9UqUX2Ey8TV
* flash: create printer dir and return errors instead of unhandled 500Adam Soltys2026-09-041-7/+13
| | | | | | | | | | The web flasher config endpoint wrote ./printer/config.txt without creating the directory, so on a fresh deploy it threw ENOENT and the JSON error body was flashed to devices as the LittleFS partition. Create the directory, validate inputs, and route errors through bail. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_014gVYGi29FjTeLdNTiutkZx
* fund: authenticated recovery of a rotated fund's old linkAdam Soltys2026-08-282-5/+30
| | | | | | | | | | | | | | | When a fund's secret id has been rotated (invalidating an exfiltrated id list), its old id 404s. Let a legit owner who bookmarked the old link find the fund again — but reveal the new id ONLY to an authenticated manager of the new fund, via a `fund:rotated:<oldid>` -> newid map. An anonymous caller or a non-owner gets the same "fund not found" as before, so an attacker replaying a leaked old id learns nothing (an open old->new redirect would defeat the rotation). The /fund/:id route now runs `optional` auth so req.user is available without requiring it; bearer funds with no managers are intentionally not recoverable this way. rotate-funds.ts records the mapping on any future rotation. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae