Compare commits

...
482 Commits
Author SHA1 Message Date
archipelago cea4fa1a5a Merge branch 'pr/work/post-190-reliability(8f394251)' into work/post190-source-acceptance
Demo images / Build & push demo images (push) Failing after 1m3s
2026-10-08 09:33:54 -04:00
archipelago 92d2855bff Approve qualified private delivery source and bind final acceptance ledger 2026-10-08 09:28:42 -04:00
archipelago 5e737fac6c docs: record passed Indee production build and fresh Yaya plan 2026-10-08 09:23:54 -04:00
archipelago e959d0eda2 Record passed combined dashboard typecheck and production artifact 2026-10-08 08:58:40 -04:00
archipelago 799cbe1bc9 docs: record post-190 source acceptance and proposal dispositions 2026-10-08 08:38:25 -04:00
archipelago 6e3fea0abb Record reviewed private IndeeHub browser acceptance harness 2026-10-08 08:32:28 -04:00
archipelago fde5a5c907 docs: refresh Indee delivery and integrated UI checkpoint 2026-10-08 08:26:02 -04:00
archipelago dd097b952c docs: complete reusable media integration and acceptance contracts 2026-10-08 08:11:16 -04:00
archipelago 58ff04f782 Update passed cutover gate and reviewed operator signing preparation 2026-10-08 08:02:00 -04:00
archipelago 6afb6abccf Record preserved-state private Indee worker image import on Yaya 2026-10-08 07:57:14 -04:00
archipelago 8d70d0312c Reconcile local main history and retain qualified dashboard fixes 2026-10-08 07:53:33 -04:00
archipelago daea20bcb2 Record passed native Indee cutover and independent final acceptance 2026-10-08 07:44:12 -04:00
archipelago dca32b078b Record integrated UI, ngit draft refresh and active cutover qualification 2026-10-08 07:36:16 -04:00
archipelago cf3c38bed0 Join existing ngit proposal history with qualified candidate 2026-10-08 07:26:22 -04:00
archipelago a28c61af69 Prepare current Indee private delivery gates and worker-aware dry plan 2026-10-08 07:18:22 -04:00
archipelago a1bc642615 Keep private device list requests separate across view generations 2026-10-08 07:15:47 -04:00
archipelago 214cebfac2 Record overloaded Indee observation and eventual native recovery 2026-10-08 07:12:25 -04:00
archipelago 83632c2439 Match standard firewall header and gate private device reads 2026-10-08 07:06:31 -04:00
archipelago 8a70232f18 Qualify firewall layouts and keep device names compatible with JS target 2026-10-08 07:06:30 -04:00
archipelago ccfa91aa57 fix(ui): make firewall settings consistent and gate device management 2026-10-08 07:06:30 -04:00
archipelago b29d58213f Record same-boot Indee rollback acceptance and stale fixture hook diagnosis 2026-10-08 07:04:45 -04:00
archipelago bd9f00ecbf Record independent staged IndeeHub hook audit 2026-10-08 07:03:36 -04:00
archipelago c83037c04e Record verified rollback and stale fixture catalog correction 2026-10-08 07:02:03 -04:00
archipelago 0008f48988 Refresh delivery checkpoint and firewall header follow-up 2026-10-08 06:38:57 -04:00
archipelago 63e21bb102 Preserve Cloud and Fleet connection journeys and retry context 2026-10-08 06:36:13 -04:00
archipelago 2ca50ae36c Unify node connection discovery requests and connected views 2026-10-08 06:36:13 -04:00
archipelago b2ecd940de Preserve completed native rollback and interrupted fixture acceptance evidence 2026-10-08 06:31:02 -04:00
archipelago 45032d3e47 Record reviewed File Browser removal safeguard evidence 2026-10-08 05:43:28 -04:00
archipelago 96259f0e35 Warn before removing File Browser and disabling local Cloud access 2026-10-08 05:41:35 -04:00
archipelago d5b73fc4e9 Record isolated automatic recovery admission qualification 2026-10-08 05:33:26 -04:00
archipelago 7c086a5615 Refresh active handover with recovery fix and operator requirements 2026-10-08 05:32:37 -04:00
archipelago 8c2828716f Track Cloud connectors working independently of File Browser 2026-10-08 05:29:13 -04:00
archipelago 6a342f665c Keep automatic recovery outside managed update ownership 2026-10-08 05:20:40 -04:00
archipelago 8ae0bdea6a docs: record prepared worker catalog amendment 2026-10-08 04:48:39 -04:00
archipelago 228e08fdf8 Record combined backend and synthetic process recovery qualification 2026-10-08 04:46:35 -04:00
archipelago 884322069a docs: record actual IndeeHub worker shutdown qualification 2026-10-08 04:33:59 -04:00
archipelago 1dbca84485 Prepare guarded Fleet delivery plan and read-only preflight 2026-10-08 04:25:04 -04:00
archipelago 06a92f1f61 test: bound synthetic child logs and delivery timeout 2026-10-08 04:24:11 -04:00
archipelago 1684d7901e test: exercise synthetic paid recovery across HTTP loss and process restart 2026-10-08 04:24:11 -04:00
archipelago eefb374978 Record fresh reciprocal Observer membership on dev and Yaya 2026-10-08 04:22:43 -04:00
archipelago 1a409900d9 Bound fresh restore database initialization by observed startup latency 2026-10-08 04:22:25 -04:00
archipelago f9661946ab Record built worker image and latest actual restore readiness gate 2026-10-08 04:15:11 -04:00
archipelago 8210544f74 docs: retain latest Indee refusal and guest readiness evidence 2026-10-08 03:56:29 -04:00
archipelago d67f5fe0a3 docs: preserve fresh no-spend paid fixture readiness evidence 2026-10-08 03:29:43 -04:00
archipelago 06f74f1623 Refresh resumed release checkpoint with current Indee qualification 2026-10-08 03:28:51 -04:00
archipelago 66c7a22d04 Recognize verified preserved relay ownership during later updates 2026-10-08 03:23:42 -04:00
archipelago 361ba45fe8 docs: record current combined suite and update retry regression 2026-10-08 02:59:45 -04:00
archipelago 105454bd61 test: preserve actual update wrapper readiness in progress regression 2026-10-08 02:49:24 -04:00
archipelago 2b2fd2b5b7 Preserve update lifecycle ownership during image byte progress 2026-10-08 02:41:22 -04:00
archipelago 54f98cbfd2 docs: record mixed Indee recovery and progress-state blocker 2026-10-08 02:38:24 -04:00
archipelago 260e13273d fix(indeehub): bound transient restore readiness retries 2026-10-08 02:13:53 -04:00
archipelago c58d1180e7 fix(indeehub): compare logical PostgreSQL restore schema 2026-10-08 01:51:21 -04:00
archipelago bca8bad822 fix(indeehub): recognize completed post-target recovery lineage 2026-10-08 01:00:31 -04:00
archipelago eabc0d93fe Record 2025-test owned override qualification 2026-10-08 00:58:24 -04:00
archipelago dc84a8b650 fix(indeehub): qualify exact legacy relay native shutdown 2026-10-08 00:44:57 -04:00
archipelago f78ee25258 Bind relay restart override to its own active maintenance role 2026-10-08 00:27:04 -04:00
archipelago 838ad745f3 fix(indeehub): bind backup checks to configured migration history 2026-10-08 00:17:14 -04:00
archipelago 884ea49238 Recognize only active owned Indee API restart override 2026-10-08 00:10:03 -04:00
archipelago 902333e336 Record 2021-test rollback and launch identity qualification 2026-10-07 23:10:41 -04:00
archipelago 32317236d9 Version supervised launch identity without promoting legacy journals 2026-10-07 23:00:37 -04:00
archipelago f42f32980d fix(indeehub): retain API restart policy across maintenance 2026-10-07 22:57:26 -04:00
archipelago 23298758f4 Record pending pre-target recovery qualification boundary 2026-10-07 22:49:39 -04:00
archipelago 07c7eb0f14 Preserve intact originals when pre-target Indee drain refuses 2026-10-07 22:35:54 -04:00
archipelago 72df1d17aa Record final 2012-test combined backend qualification 2026-10-07 21:58:34 -04:00
archipelago 9964b5c158 fix(indeehub): prove legacy API child shutdown before backup 2026-10-07 21:40:53 -04:00
archipelago a30c12193d fix(indeehub): prove idle legacy worker termination before backup 2026-10-07 21:18:36 -04:00
archipelago 470d4f3944 Record frozen Fleet UI artifact qualification and delivery gate 2026-10-07 21:06:03 -04:00
archipelago c409fd1fe5 Align companion UAT with accepted playback and deferred artwork 2026-10-07 21:02:26 -04:00
archipelago 125a044a75 Record Fleet cache qualification and legacy Indee drain blocker 2026-10-07 21:00:52 -04:00
archipelago ff5220869c Label Fleet alert provenance and distrust cached identity claims 2026-10-07 20:59:18 -04:00
archipelago ed94a46b07 docs: update current helper blocker and combined UI pass 2026-10-07 20:54:42 -04:00
archipelago b03d3c890d fix(indeehub): inspect nginx through fixed system service 2026-10-07 20:50:21 -04:00
archipelago fd98b38364 Show unavailable Fleet history explicitly 2026-10-07 20:42:18 -04:00
archipelago f5a1806ae3 Expose Fleet alert failures and distinguish report provenance 2026-10-07 20:42:17 -04:00
archipelago 07dd1d545c docs: put current delivery gates before historical handover receipts 2026-10-07 20:39:15 -04:00
archipelago 9268930c10 Prevent unsigned Fleet collectors from claiming federation provenance 2026-10-07 20:35:50 -04:00
archipelago c7bf4db085 docs: defer failed notification artwork until all other work is complete 2026-10-07 20:34:11 -04:00
archipelago 1e4a7460ce Record failed phone artwork acceptance and operator deferral 2026-10-07 20:29:55 -04:00
archipelago d62769067d docs: retain deployed artwork and corrected VM startup evidence 2026-10-07 20:24:41 -04:00
archipelago 3dacf217a0 Record rental guard and corrected artifact qualification boundaries 2026-10-07 20:21:10 -04:00
archipelago 4dde14bf5f Guard rental purchases against unresolved alternate payment rails 2026-10-07 20:17:03 -04:00
archipelago 41dc66574d docs: pin selected demo bytes and close optional copy helper 2026-10-07 20:16:08 -04:00
archipelago 7877300617 docs: record artwork delivery and selected IndeeHub demo source 2026-10-07 20:07:22 -04:00
archipelago d23da226a0 Record qualified artwork UI delivery to dev and Yaya 2026-10-07 19:57:34 -04:00
archipelago 044ecdd0f6 docs: retain current delivery and maintenance qualification boundaries 2026-10-07 19:55:59 -04:00
archipelago 2ccc0381bc docs: reconcile companion guide with confirmed playback and PiP 2026-10-07 19:53:09 -04:00
archipelago 01a55d2de7 fix(indeehub): run maintenance controller in retained user scope 2026-10-07 19:53:07 -04:00
archipelago c47d9d7c14 Clarify boundary for historical payment receipt recovery 2026-10-07 19:46:20 -04:00
archipelago ae01637dfa Record passing combined payment and lifecycle backend qualification 2026-10-07 19:38:40 -04:00
archipelago 431b904ee9 Record continuing independent build and delivery ownership 2026-10-07 19:36:22 -04:00
archipelago dfbc56402f Record verified ngit publication of original 1.9.0 ISO 2026-10-07 19:25:09 -04:00
archipelago 516941192d Record bounded HTTPS iframe and tab acceptance evidence 2026-10-07 19:12:05 -04:00
archipelago 60bd728a04 Verify native notification artwork with real JPEG regression 2026-10-07 19:00:20 -04:00
archipelago b0b95810e0 fix(indeehub): preserve reviewed runtimes across reconciliation and lifecycle 2026-10-07 18:51:32 -04:00
archipelago d19124f5dc Retain incomplete backend qualification attempts honestly 2026-10-07 18:49:20 -04:00
archipelago 0c1d1b5796 Allow notification thumbnails from admitted LAN app origins 2026-10-07 18:41:06 -04:00
archipelago 80ebf75313 Plan scoped Mesh file selection and durable paid delivery 2026-10-07 18:38:48 -04:00
archipelago b15f0dc9ea Record APK57 acceptance and combined UI qualification with rollback 2026-10-07 18:37:04 -04:00
archipelago 7383d47bad Add local companion playback diagnostics for failed background audio 2026-10-07 18:10:54 -04:00
archipelago 4ead8376e7 Specify version-checked Cloud source integration boundaries 2026-10-07 17:48:02 -04:00
archipelago d8f5145ff3 Record independent qualification progress and Fleet browser evidence 2026-10-07 17:37:24 -04:00
archipelago dc977fe0bd Record current paid-cache preservation evidence 2026-10-07 17:34:10 -04:00
archipelago 18b087202d Prevent alternate and legacy payments bypassing file recovery 2026-10-07 17:32:03 -04:00
archipelago 65d265f267 Keep Fleet outage state visible and reject superseded responses 2026-10-07 17:26:42 -04:00
archipelago 9244bcef15 Record failed companion background playback acceptance 2026-10-07 17:09:56 -04:00
archipelago 2bfa84efa9 Resolve complete reviewed IndeeHub stack before image preparation 2026-10-07 17:04:00 -04:00
archipelago cade9cb389 docs: record companion delivery and completed Web5 footer acceptance 2026-10-07 16:46:53 -04:00
archipelago fd3be7207b Reconcile qualified IndeeHub helper preparation status 2026-10-07 16:20:47 -04:00
archipelago 235f6d04d5 feat(companion): retain audio with native media controls 2026-10-07 16:10:10 -04:00
archipelago b9c75b2141 Prepare legacy IndeeHub identity and original recipes before catalog selection 2026-10-07 16:02:28 -04:00
archipelago f81cc4ecdb Verify fresh IndeeHub volume restores before supervised cutover 2026-10-07 14:51:40 -04:00
archipelago 573a58622f Append Mesh file picker and paid sharing task after public sharing 2026-10-07 14:45:45 -04:00
archipelago d4f3cceb52 Verify fresh IndeeHub backup restores and record remaining release tasks 2026-10-07 14:43:22 -04:00
archipelago 7fb7ee80f2 Integrate retained updater candidate for release qualification 2026-10-07 13:50:26 -04:00
archipelago 68082cec49 Verify complete maintenance backup hashes and restored database commitments 2026-10-07 13:50:11 -04:00
archipelago 7e1eb65f3b Qualify companion 0.5.35 PiP candidate and record dev delivery 2026-10-07 13:41:43 -04:00
archipelago a9a1975163 Add comprehensive release work handover 2026-10-07 12:02:57 -04:00
archipelago ce3ec96528 Keep companion PiP test compatible with project target 2026-10-07 11:34:49 -04:00
archipelago 0d8decb366 Draft retained Cloud video picture-in-picture for companion 2026-10-07 11:31:10 -04:00
archipelago 4d938cd5aa Add read-only release UAT acceptance checklist 2026-10-07 11:20:31 -04:00
archipelago 3f96be2c0a Handle nullable tunnel addresses in firewall status 2026-10-07 10:16:01 -04:00
archipelago ef6c10f06e Add central firewall and tunnel status screen 2026-10-07 09:12:42 -04:00
archipelago b50bf6159a Explain unaffordable fee bump quotes 2026-10-07 09:12:41 -04:00
archipelago c57119e9a7 Explain unaffordable fee bump quotes 2026-10-07 08:42:08 -04:00
archipelago beb0dbc1f4 Add central firewall and tunnel status screen 2026-10-07 08:36:25 -04:00
archipelago fe820e8c4d Track Framework bump quote wallet change failure 2026-10-07 08:32:39 -04:00
archipelago 7e43f673a0 Track companion background media lifecycle 2026-10-07 08:27:22 -04:00
archipelago 6e38d0c093 Enable companion Cloud video picture-in-picture 2026-10-07 08:05:56 -04:00
archipelago 973dbeb449 Register on-chain purchase HTTP handler 2026-10-07 07:49:15 -04:00
archipelago 6547ae05fa Integrate two-phase on-chain purchase recovery 2026-10-07 07:49:02 -04:00
archipelago 558f097fd6 Bound federated sync and show progress feedback 2026-10-07 07:48:45 -04:00
archipelago a64dd77efa Bound federated sync and show progress feedback 2026-10-07 07:25:44 -04:00
archipelago 0338a193db Track federated node sync reliability and progress UX 2026-10-07 07:22:15 -04:00
archipelago c4b22628af Record isolated on-chain recovery qualification 2026-10-07 07:09:39 -04:00
archipelago 808695d715 Remove misleading successful integration settings toast 2026-10-07 06:15:14 -04:00
archipelago 76d4c5c9a2 Revert "Keep admitted media frames in one stable dashboard session tree"
This reverts commit 910ed151f1.
2026-10-07 05:37:12 -04:00
archipelago 910ed151f1 Keep admitted media frames in one stable dashboard session tree 2026-10-07 05:23:08 -04:00
archipelago 2512a9f62b Retain verified restored units and validate original installer identity bindings 2026-10-07 02:57:31 -04:00
archipelago 541f073a2e Verify original database commitments before releasing restored IndeeHub 2026-10-07 02:50:09 -04:00
archipelago 39852ab381 Preserve reviewed managed unit recipes after update completion 2026-10-07 02:49:31 -04:00
archipelago 5a9aac18ea Qualify idle legacy process termination without inventing completed work 2026-10-07 02:35:21 -04:00
archipelago f79ecd11ae Integrate legacy managed update maintenance and fenced recovery before reconciliation 2026-10-07 02:34:51 -04:00
archipelago 46fdc2764c Recover aborted maintenance without fabricated drain or foreign fence changes 2026-10-07 02:26:51 -04:00
archipelago 6d450caebf Add operation-owned legacy IndeeHub maintenance controller draft 2026-10-07 02:24:50 -04:00
archipelago 49232fd547 Retain media sessions admitted after installed app state arrives 2026-10-07 02:20:49 -04:00
archipelago 6e7ea8b9d6 Add managed runtime adapter and require operation-owned write drain through update 2026-10-07 02:05:52 -04:00
archipelago 6c030a8109 Plan reviewed Quadlet migrations and preserve private writable-layer snapshots 2026-10-07 01:52:36 -04:00
archipelago 68eeb6396d Retain update recovery holds and journal supervised runtime restoration 2026-10-07 01:49:01 -04:00
archipelago fdc596854e Match legacy installed image digests to verified native media policy 2026-10-07 01:46:58 -04:00
archipelago c2c4d9151c Construct rental metadata regression fixture with typed receipt 2026-10-07 01:25:35 -04:00
archipelago 45579e53c7 Draft retained-container update journal and original-runtime recovery 2026-10-07 01:24:50 -04:00
archipelago 1bbf85e0d4 Recover stopped update staging draft on current private-image preflight 2026-10-07 01:14:49 -04:00
archipelago 8389326c97 Use supported array access in launch policy regression tests 2026-10-07 01:14:05 -04:00
archipelago a5304518c8 Align retained media expiry regression with session admission policy 2026-10-07 01:11:47 -04:00
archipelago ba1de69fc5 Reject changed rental metadata before background verification 2026-10-07 01:06:57 -04:00
archipelago cffb74326a Keep ordinary app launches available while optional policy loads 2026-10-07 01:05:33 -04:00
archipelago f1fb388ded Draft generic audio manifest admission and public media adapter 2026-10-07 01:05:33 -04:00
archipelago 30b5975534 Attribute on-chain receipts to exact outputs and stop ambiguous Fedimint fallback 2026-10-07 00:51:14 -04:00
archipelago 687ec881ca Keep preparation size available after moving verification binding 2026-10-07 00:50:53 -04:00
archipelago fba3273c67 Add durable buyer-bound Lightning recovery and explicit native retry
Preserve original invoice preimages, private snapshots and exposure provenance; serialize rail admission and retire native-only failures before explicit replacement. Qualify 55 focused UI tests and vue-tsc. Expanded 17 engine cases and combined backend acceptance remain pending; six earlier engine cases passed in isolation. No live payment or publication.
2026-10-07 00:32:39 -04:00
archipelago ed96df0ac3 Draft explicit rental readiness and start with verified chunk delivery 2026-10-07 00:23:43 -04:00
archipelago 697aabeec3 Prepare stack update images before downtime and reuse private digest imports 2026-10-07 00:19:03 -04:00
archipelago 40fd91b9e1 Limit wildcard TLS migration to an observed tailnet bind conflict 2026-10-07 00:01:53 -04:00
archipelago fdee8658c3 Keep listener repair backups in their own support directory 2026-10-06 23:56:52 -04:00
archipelago 58a0c6ef64 Repair managed nginx listeners and verify reload acceptance 2026-10-06 23:54:20 -04:00
archipelago 13d1b459fc Record three-node purchase deployment and remaining live acceptance gates 2026-10-06 23:39:39 -04:00
archipelago 49703d7e88 Integrate recoverable native purchases, registered rentals and explicit payment consent 2026-10-06 22:44:06 -04:00
archipelago e4eae71314 Record payment dialog deployment and preservation checks on three nodes 2026-10-06 21:44:36 -04:00
archipelago 47cd915e40 Keep peer payment callbacks bound to their original modal operation 2026-10-06 21:27:55 -04:00
archipelago 530c497277 Record three-node recovery backend deployment and real V4V Browse acceptance 2026-10-06 21:25:01 -04:00
archipelago cb79ac5321 Qualify real V4V Browse launch and native playback after deployment 2026-10-06 21:20:55 -04:00
archipelago b52214f7a0 Qualify durable purchase and media primitives and preserve app launch paths 2026-10-06 20:50:44 -04:00
archipelago a876dc3d0b Verify rendered native player artwork and expose missing manifest entry paths 2026-10-06 20:31:00 -04:00
archipelago 057150d377 Qualify real native music login and retained playback on mobile and desktop 2026-10-06 20:13:08 -04:00
archipelago 5d66d2758d Record private Yaya music image deployment and signed catalog validation 2026-10-06 20:09:43 -04:00
archipelago ee7b9b897a Record deployed Lightning retry UI and remaining payment acceptance 2026-10-06 20:06:05 -04:00
archipelago d94ff097d2 Recover failed Lightning file attempts without blocking other payment methods 2026-10-06 19:59:47 -04:00
archipelago 0dda84e5c4 Bind purchase peer proofs to exact requests and qualify recovered backend modules 2026-10-06 19:22:52 -04:00
archipelago abcf77eae3 Prepare durable node media snapshots and compatible registration receipts 2026-10-06 19:22:20 -04:00
archipelago cae0099d6f Recover incoming settlement and persist immutable purchase journals 2026-10-06 19:22:10 -04:00
archipelago 1c6daab92a Preserve recovered task scope and document private demo delivery and media integration 2026-10-06 18:30:10 -04:00
archipelago f28c334c72 Qualify managed V4V native login and retained playback without payments 2026-10-06 17:55:34 -04:00
archipelago 07de8de380 Add native music queue controls, artwork and settled V4V banner 2026-10-06 17:52:33 -04:00
archipelago e3775771ca Verify recovery support before spending and route node catalogs through nginx 2026-10-06 17:42:11 -04:00
archipelago 4228ce443c Make saved Cashu sends recoverable and record deferred connection UX scope 2026-10-06 17:19:30 -04:00
archipelago af85d2be53 Incorporate acknowledged firewall and tunnel settings handover 2026-10-06 17:04:17 -04:00
archipelago 876a069d06 Track firewall tunnel settings handover and certificate identity gap 2026-10-06 16:57:03 -04:00
archipelago 96dfed1ec5 Validate mint proof states and protect outgoing sends during seed restore 2026-10-06 16:50:34 -04:00
archipelago 3211852acd Reserve send inputs and commit recovered wallet results exactly once 2026-10-06 16:42:24 -04:00
archipelago 048007ea2d Record two-node iframe deployment and remaining recovery gates 2026-10-06 16:38:35 -04:00
archipelago d4b359dbae Persist immutable private send recovery records with guarded transitions 2026-10-06 16:29:34 -04:00
archipelago a4ede20204 Prepare and recover Cashu swaps from exact persisted request material 2026-10-06 15:58:22 -04:00
archipelago 9c95b8732f Serialize wallet mutations and preserve network and seed recovery state 2026-10-06 15:39:44 -04:00
archipelago 9f0df2ac44 Preserve app gate TLS access through provisioning and certificate rotation 2026-10-06 15:19:38 -04:00
archipelago 719e723816 Preserve damaged empty wallets and durably save private wallet files 2026-10-06 15:19:38 -04:00
archipelago 88d473f6e1 Normalize only loopback authorities in embedded app runtime URLs 2026-10-06 15:05:17 -04:00
archipelago c3bfbe8519 Track HTTPS embedded app gate and remaining payment recovery boundaries 2026-10-06 14:56:34 -04:00
archipelago 9771378bfd Reject counter documents that omit recovery state 2026-10-06 14:45:42 -04:00
archipelago 12e2a82b28 Stop ecash recovery failures from silently reusing or abandoning backup state 2026-10-06 14:34:25 -04:00
archipelago a7cc7084f2 Record IndeeHub paging, cache and migration qualification status 2026-10-06 14:18:32 -04:00
archipelago 2a2ae7da02 Record both-node launcher qualification and real IndeeHub login checks 2026-10-06 13:17:18 -04:00
archipelago e8683aa5b1 Preserve slow custom-app overlays through their loading deadline 2026-10-06 13:01:44 -04:00
archipelago a49d4d7128 Keep slow app sessions alive and defer automatic companion prompts 2026-10-06 12:55:47 -04:00
archipelago a3f0bf0af7 Select peer ecash wallet before spending and forbid ambiguous fallback 2026-10-06 12:06:14 -04:00
archipelago e844bbe1c7 Record both-node signer deployment and eight served-browser checks 2026-10-06 11:56:32 -04:00
archipelago 08c93f4aad Unify legacy overlay signing with the queued iframe consent bridge 2026-10-06 11:43:33 -04:00
archipelago 367c32bb08 Record two-node signer UI qualification and reusable browser check 2026-10-06 11:04:39 -04:00
archipelago cc9f02dfd2 Keep embedded app URL stable as initial runtime state arrives 2026-10-06 10:56:42 -04:00
archipelago 715e86c901 Queue native signer requests without losing pending app consent 2026-10-06 10:41:58 -04:00
archipelago 8615e0bc8d Record live bilateral FIPS purchase and restart qualification 2026-10-06 10:10:55 -04:00
archipelago cc24055d6c Record browser qualification and cleared deployment coordination 2026-10-06 08:54:21 -04:00
archipelago 805e5bcae1 Track IndeeHub catalog and library qualification 2026-10-06 08:22:43 -04:00
archipelago 6c84a6a771 Record passing atomic sharing qualification 2026-10-06 08:16:21 -04:00
archipelago f3264c8de5 Test fail-closed sharing against older backends 2026-10-06 07:56:44 -04:00
archipelago 19207282f9 Publish content with an atomic price and visibility policy 2026-10-06 07:51:00 -04:00
archipelago 65b51b98f4 Record combined streaming and Fleet qualification results 2026-10-06 07:23:46 -04:00
archipelago 081c8215c1 Record operator authorization for expanded small-payment testing 2026-10-06 07:22:15 -04:00
archipelago 4b6d102415 Map IndeeHub integration boundaries and bounded live payment authorization 2026-10-06 07:18:45 -04:00
archipelago d46f6ceeeb Format payment-method and interrupted-delivery regressions 2026-10-06 07:10:48 -04:00
archipelago 607f54260e Exercise interrupted HTTP body in on-chain delivery recovery regression 2026-10-06 07:03:31 -04:00
archipelago b984b2a698 Check durable file payments against their actual payment method 2026-10-06 07:03:11 -04:00
archipelago cb33fe26e4 Discard late Fleet history replies after changing selected node 2026-10-06 06:54:46 -04:00
archipelago a0cb29744d Merge branch 'work/post-190-onchain-stream' into work/post-190-session-key 2026-10-06 06:50:55 -04:00
archipelago c2d2b00c5c Age Fleet status locally during stalled or paused telemetry refresh 2026-10-06 06:50:29 -04:00
archipelago ea3367ed45 Record streaming qualification failure and remaining payment gates 2026-10-06 06:44:02 -04:00
archipelago 559883b007 Merge commit '1971aeb3' into work/post-190-onchain-stream 2026-10-06 06:43:40 -04:00
archipelago 2e761666d5 Stream confirmed on-chain file deliveries into the owned cache 2026-10-06 06:43:19 -04:00
archipelago 1971aeb3e3 Fail closed on corrupt peer records before content authentication 2026-10-06 06:40:59 -04:00
archipelago b8e512fed6 Keep authentication failures refundable and bound inline peer previews 2026-10-06 06:27:06 -04:00
archipelago 8ffbf5ff6e Check on-chain item visibility and reduce test debug-data pressure 2026-10-06 06:17:49 -04:00
archipelago 744923f7d3 Merge branch 'work/post-190-session-key' into work/post-190-peer-content-auth 2026-10-06 06:09:26 -04:00
archipelago 140f4d91cd Correct cached purchase test case tuple after API update 2026-10-06 06:09:25 -04:00
archipelago a9edcd6b3e Verify scoped peer identity proofs before restricted content access 2026-10-06 06:07:55 -04:00
archipelago 6fba95fe5a Exercise existing purchase regressions through streamed Files copies 2026-10-06 06:03:20 -04:00
archipelago 9ce04627dd Stream purchased files into durable cache and avoid duplicate concurrent payments 2026-10-06 05:48:05 -04:00
archipelago df7677d23f Keep payment assertion outside borrowed regression future 2026-10-06 05:39:24 -04:00
archipelago 2fee0339cb Prepare large paid files on disk and stream peer responses in bounded chunks 2026-10-06 05:31:19 -04:00
archipelago 051dc7e3df Generate paid previews server-side and enforce sharing boundaries 2026-10-06 05:18:53 -04:00
archipelago 11f016a944 Qualify candidate media playback without changing live dashboard files 2026-10-06 04:40:59 -04:00
archipelago c78b6021c3 Keep Web5 card actions at the bottom when neighbours grow 2026-10-06 04:36:25 -04:00
archipelago f4fa575fa0 Record integrated follow-up qualification and deployment hold 2026-10-06 04:18:39 -04:00
archipelago 6c985b8da3 Integrate mining launch handoff with follow-up app and media fixes 2026-10-06 04:13:08 -04:00
archipelago a94b9c64aa Pin node-only V4V demo to verified registry image 2026-10-06 04:13:08 -04:00
archipelago 69cd4021f2 fix: resolve node demo launch policy before opening its player 2026-10-06 03:35:21 -04:00
archipelago a67cffe88d test: qualify deployed dashboard controls with real V4V playback 2026-10-06 03:20:40 -04:00
archipelago 2b04f9a79c docs: record verified dev and Yaya deployment recovery gates 2026-10-06 03:19:40 -04:00
yaya 2fad10c8de Show DATUM login credentials and document complete app launch requirements 2026-10-06 08:13:20 +01:00
yaya 3fc37642cd fix(apps): preserve manifest presentation during installation 2026-10-06 08:13:20 +01:00
archipelago b2ada09b7c docs: record follow-up deployment and mobile playback gates 2026-10-06 03:10:49 -04:00
archipelago 883c5a7c76 test: keep navigation regression compatible with frontend target 2026-10-06 03:09:47 -04:00
archipelago 7946ef8636 fix: restore mobile navigation clearance when closing app playback 2026-10-06 03:07:38 -04:00
archipelago 631c2bc73a test: retain repeatable real V4V media bridge qualification 2026-10-06 02:52:33 -04:00
archipelago 57923b0a5f fix: synchronize doctor through host namespace before reconciliation 2026-10-06 02:42:50 -04:00
archipelago e5b52b84a5 Delegate orphan cleanup to the backend instead of killing containers across stores 2026-10-06 02:04:19 -04:00
archipelago 5c0b6402ed Require standard MeshCore public channels and defer custom channels 2026-10-06 02:00:56 -04:00
archipelago e110dd1c0c Track final MeshCore two-radio reliability and settings acceptance task 2026-10-06 01:57:55 -04:00
archipelago 67a24d6a65 Record all follow-up task states and distinguish live acceptance from candidates 2026-10-06 01:47:40 -04:00
archipelago a2e6138279 Keep permission regression probe independent of private checkout paths 2026-10-06 01:34:57 -04:00
archipelago aa10bd1247 Fail closed when persistent session signing material is unavailable 2026-10-06 01:30:04 -04:00
archipelago 5492080526 Record Yaya session repair, network diagnosis and remaining acceptance gates 2026-10-06 01:14:13 -04:00
archipelago 7e11f78eb4 Recover stale authenticated CSRF cookies and distinguish interface fetch failures 2026-10-06 01:08:31 -04:00
archipelago 2fa82e4506 Keep peer file downloads and previews on mandatory FIPS transport 2026-10-06 00:53:34 -04:00
archipelago 45b3e48779 Keep media bridge tests compatible with the dashboard TypeScript target 2026-10-06 00:53:34 -04:00
archipelago e54f83df8f Add signed node-scoped demo catalogs and retained app media sessions 2026-10-06 00:53:34 -04:00
archipelago 131c39cf74 Restrict orphan container cleanup to its owning user and Podman storage 2026-10-06 00:52:40 -04:00
archipelago 104e0601ff Use the existing futures-util dependency for media streaming 2026-10-05 23:57:12 -04:00
archipelago bf7fb425eb Require FIPS for peer playback and stream owned media with bounded reads 2026-10-05 23:52:44 -04:00
archipelago 9af49291e9 Bind accepted npub requests to peer identities and serialize cancellation 2026-10-05 23:52:44 -04:00
archipelago fefcbfdbc4 Accept authenticated npub-only peering replies with validated DID keys 2026-10-05 23:52:44 -04:00
archipelago 29668d3adb Keep replacement dialogs open when handing off modal history 2026-10-05 23:52:43 -04:00
archipelago 83ba98ab42 Guide AI connection setup with private node credentials and explicit providers 2026-10-05 23:41:29 -04:00
archipelago 0c25449566 Show durable connection requests and timestamped node availability 2026-10-05 23:10:34 -04:00
archipelago 10d31ae13c Persist encrypted peer approval delivery and bind discovery invite identities 2026-10-05 22:27:43 -04:00
archipelago e97f958f45 fix: bound monitoring subprocesses and collect fresh system readings 2026-10-05 21:31:32 -04:00
archipelago 3d0c67eb9b fix: retain native signer session through repeated public-key lookups 2026-10-05 21:18:27 -04:00
archipelago 6f098cd9c2 fix: clone public identity fields before cross-frame signer handoff 2026-10-05 21:11:03 -04:00
archipelago d849a2f794 fix: clone public identity fields before cross-frame signer handoff 2026-10-05 20:59:31 -04:00
archipelago 041f1fa2d3 fix: report collected fleet metrics and distinguish unavailable readings 2026-10-05 20:31:40 -04:00
archipelago cfd9a596c0 docs: plan node flows and record follow-up qualification 2026-10-05 19:53:05 -04:00
archipelago eaecab16ca fix: clarify Web5 connection actions and remove cosmetic wallet polling 2026-10-05 19:53:05 -04:00
archipelago 9a041bed18 fix: send peer replies through managed Nostr relays 2026-10-05 19:53:05 -04:00
archipelago 053f03be49 fix: validate legacy JSON before migrating encrypted state 2026-10-05 19:53:05 -04:00
archipelago cedfbb2b07 docs: record public release verification and storage follow-up 2026-10-05 19:52:07 -04:00
archipelago 7ae812e3f6 chore: publish verified 1.9.0-alpha OTA and app catalog 2026-10-05 19:13:08 -04:00
archipelago bc386965da docs: require FIPS for distributed media streaming 2026-10-05 18:59:49 -04:00
archipelago 7abd04a7f6 docs: record corrected signed OTA reboot acceptance
Demo images / Build & push demo images (push) Failing after 39s
2026-10-05 18:56:17 -04:00
archipelago 441733646f chore: stage signed 1.9.0-alpha release metadata 2026-10-05 18:50:26 -04:00
archipelago ccf823590a fix: reject stale first-boot scripts in OTA release payloads 2026-10-05 18:44:46 -04:00
archipelago d9775ac144 docs: require headless node qualification and IndeeHub app delivery 2026-10-05 18:25:18 -04:00
archipelago 0925c58821 docs: track Fleet metrics and secure FIPS performance work 2026-10-05 18:22:51 -04:00
archipelago 2d27f9c475 docs: track node availability and navigation latency follow-ups 2026-10-05 18:21:02 -04:00
archipelago 868e46fac9 docs: queue companion Fleet and AIUI setup follow-ups 2026-10-05 18:18:58 -04:00
archipelago 2aa77d1b7b docs: record installer acceptance and post-release work 2026-10-05 18:18:11 -04:00
archipelago a6b9e7ab49 fix: preserve apps and diagnostics when first-boot setup retries 2026-10-05 17:21:08 -04:00
archipelago a902cc84fe test: qualify built demo images through mobile and upload flows 2026-10-05 16:48:52 -04:00
archipelago 157c9ec055 fix: keep public demo online during optional upstream DNS outages 2026-10-05 16:15:41 -04:00
archipelago 415826f0a6 fix: package the qualified UI in release ISOs 2026-10-05 16:02:48 -04:00
archipelago 69857c4ace fix: require explicit dispatch for public demo redeployment 2026-10-05 15:42:46 -04:00
archipelago e6e46a1427 fix: build demo AIUI from the reviewed source revision 2026-10-05 15:34:41 -04:00
archipelago e0b2181ae9 fix: isolate NPM upstream TLS sessions across public domains 2026-10-05 15:26:28 -04:00
archipelago 446fa7b7fd fix: retain management guard through legacy runtime install and rollback 2026-10-05 15:08:42 -04:00
archipelago ba8b1f29b2 docs: record accepted companion and Yaya deployment 2026-10-05 14:44:03 -04:00
archipelago b8266c2872 fix: support resumable Cloud uploads in the public demo 2026-10-05 14:41:28 -04:00
archipelago 5aa74d0513 fix: qualify mobile Cloud viewer and companion downloads 2026-10-05 14:41:08 -04:00
archipelago daac47cac4 fix: harden node upgrades and prepare 1.9.0-alpha 2026-10-05 12:43:49 -04:00
archipelago 138a541d01 docs: make ngit canonical and gate mirror publication 2026-10-05 11:47:12 -04:00
archipelago 833c939220 docs: make alpha status and funds risk prominent 2026-10-05 10:26:10 -04:00
archipelago 2c1bcacf0a Test Nostr encryption compatibility and forged relay messages
Demo images / Build & push demo images (push) Failing after 52s
2026-10-05 10:00:41 -04:00
archipelago f1d0092e57 Validate app owner keys and match identity picker whitespace rules 2026-10-05 09:31:46 -04:00
archipelago 7dfb0e0013 Merge opt-in app owner identity placeholder
nevent1qqs9d76qm6f5xj2vrtjfnkqz5exrc8r0s9zev4f672kqyd0wjh7wwvqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcx2tvaw
2026-10-05 09:29:18 -04:00
archipelago 775d7b9877 Merge Nostr 0.44 security fixes
nevent1qqsgj7l3gewl20m6xxxeu89zsmjy9prvc9g8ggkr6cysa5p9tc3hc2gpz3mhxue69uhhyetvv9ujumn8d96zuer9wcyr3wqc
2026-10-05 09:29:08 -04:00
TheCryptoDonkey c18ebd7f5b fix: update nostr to 0.44.7 and nostr-relay-pool to 0.44.3
Patch releases within 0.44 that clear eleven RustSec advisories against
the versions in the lock: RUSTSEC-2026-0216, -0219, -0224 to -0232.
They cover NIP-04 and NIP-44 decryption panics and resource exhaustion,
Debug output exposing NIP-46 and NIP-60 credentials, and the relay pool's
handling of unverified events. No other package changes.
2026-10-03 11:11:06 +02:00
TheCryptoDonkey 494d248356 feat: add NODE_IDENTITY_PUBKEYS derived-env placeholder
Lets an app grant the node's users owner rights, e.g. a Blossom server's
allowed uploaders. The value is the Nostr keys of the identities the app
identity picker offers for NIP-07 signing, chosen by the same rule as
NostrIdentityPicker.vue, so the node's own appliance identity is never
included. It is resolved only for manifests that template it, and an
empty set is an error rather than an empty owner list.

identity.list now shares its is_node test with the new helper.
2026-10-03 11:10:29 +02:00
archipelago 3acefecc24 Serve the shared Mempool explorer through the Angor indexer 2026-10-01 16:03:19 -04:00
archipelago 19c49c6605 Remove remaining blur from transaction filter container 2026-10-01 14:48:51 -04:00
archipelago d6e0c142c6 Return retryable payment status errors and record NPM release gate 2026-10-01 14:24:24 -04:00
archipelago 57729f8e18 Record candidate deployment and corrected payment evidence 2026-10-01 12:15:41 -04:00
archipelago 4fdadad89d Record final build status and live resource checks 2026-10-01 10:46:44 -04:00
archipelago f4d3455496 Fix paid-file recovery, app lifecycle regressions and wallet controls
Demo images / Build & push demo images (push) Failing after 1m10s
2026-10-01 10:31:55 -04:00
archipelago 227174e541 docs: close 1.8.22 publication with verified Git and ngit assets 2026-10-01 06:19:12 -04:00
archipelago 2e72b38778 release: publish verified signed 1.8.22 OTA and app catalog 2026-10-01 06:15:32 -04:00
archipelago 0be7aee49d docs: record final 1.8.22 OTA and ISO acceptance 2026-09-30 19:50:01 -04:00
archipelago 6d5f3ffb85 fix: select NPM admin port regardless of binding order
Demo images / Build & push demo images (push) Failing after 34s
2026-09-30 18:24:35 -04:00
archipelago d1bc1273d4 fix: replace cached container doctor before ISO first boot 2026-09-30 17:52:30 -04:00
archipelago 96fb5a4f19 fix: prevent stale snapshots resurrecting orphaned dashboards
Demo images / Build & push demo images (push) Failing after 36s
2026-09-30 17:45:18 -04:00
archipelago f91c1f33db fix: keep apps running when network diagnostics fail 2026-09-30 17:34:11 -04:00
archipelago 02b840f2d1 chore: prepare 1.8.22-alpha release candidate
Demo images / Build & push demo images (push) Failing after 36s
2026-09-30 16:45:43 -04:00
archipelago f992780957 fix: probe Angor IPv4 health endpoint inside the actual image 2026-09-30 16:40:16 -04:00
archipelago c82c1eee98 fix: prevent NPM tunnel collisions and false app health restarts 2026-09-30 16:30:40 -04:00
archipelago 2992443d5d docs: record verified NPM tunnel port conflict and node repair 2026-09-30 16:12:04 -04:00
archipelago 259c353147 Clear completed candidate deployment instructions 2026-09-30 13:46:50 -04:00
archipelago 1724ea05d1 Show readiness reason first and record live dashboard acceptance
Demo images / Build & push demo images (push) Failing after 45s
2026-09-30 13:46:16 -04:00
archipelago c1e20a71ae Check companion dashboards and omit headless UI waiting messages
Demo images / Build & push demo images (push) Failing after 37s
2026-09-30 13:28:37 -04:00
archipelago bf56956790 Record UI acceptance and remaining normal-startup release gate 2026-09-30 12:57:35 -04:00
archipelago 2f1a3ade07 Promote runtime manifests before starting app reconciliation 2026-09-30 12:50:46 -04:00
archipelago ef8254272c Name waiting apps, align card actions, and update Angor icon
Demo images / Build & push demo images (push) Failing after 39s
2026-09-30 12:43:53 -04:00
archipelago d50be13232 Normalize Mempool frontend aliases in restored app inventory 2026-09-30 12:41:15 -04:00
archipelago 439b55a236 Use manifest names for new services and document release acceptance
Demo images / Build & push demo images (push) Failing after 38s
2026-09-30 12:20:29 -04:00
archipelago 5ab65f7581 Preserve apostrophes in Quadlet commands and record funded acceptance 2026-09-30 12:08:35 -04:00
archipelago 169bf77de6 Add headless Angor services and shared-index install guard
Demo images / Build & push demo images (push) Failing after 43s
2026-09-30 11:52:19 -04:00
archipelago 7c4169867c docs: consolidate release scope and record migration recovery checks 2026-09-30 10:52:41 -04:00
archipelago acf544500f fix(apps): preserve state across runtime repairs and restore Gitea SSH 2026-09-30 10:46:38 -04:00
archipelago 7d767c8cb0 fix(catalog): gate network migration manifests on backup support 2026-09-30 10:08:56 -04:00
archipelago eb3ccfa00b Merge branch 'fix/gitea-portainer-20260930' 2026-09-30 09:57:49 -04:00
archipelago eda28c4cd6 fix(portainer): repair same-node Git routing with recoverable network migration 2026-09-30 09:57:25 -04:00
archipelago d69e845216 Merge remote-tracking branch 'origin/main'
Demo images / Build & push demo images (push) Failing after 1m10s
2026-09-30 09:32:20 -04:00
archipelago dc962c53b0 docs: record live lifecycle acceptance and next release blockers 2026-09-30 09:31:18 -04:00
archipelago 6ac26f637c fix(apps): preserve lifecycle state and wait for usable launch endpoints 2026-09-30 09:10:30 -04:00
archipelago 27d81e956d fix(installer): ship all app build contexts and refresh GitWorkshop dependencies 2026-09-30 09:09:21 -04:00
archipelago eb39391223 fix(ui): keep Bitcoin version choices readable in kiosk 2026-09-30 09:09:21 -04:00
chaum b02ba4100d Merge pull request 'fix(files): save purchased files atomically with rootless ownership' (#162) from fix/filebrowser-purchase-filing into main 2026-09-30 12:58:36 +00:00
chaum 3daea6623b Merge pull request 'fix(ecash): prevent paid-download replay and read failures after charging' (#161) from fix/ecash-paid-download-v2-keyset into main 2026-09-30 12:58:33 +00:00
archipelago d42f448e31 docs: close verified 1.8.21 OTA and ISO publication 2026-09-30 07:46:05 -04:00
archipelago 1566f1bb00 docs: record tested paid-download PRs for next release 2026-09-30 07:34:18 -04:00
archipelago 0677924a64 Merge current main and make purchase filing atomic under concurrent writes 2026-09-30 07:26:51 -04:00
archipelago 971d477795 Merge current main and harden paid-download delivery 2026-09-30 07:25:47 -04:00
archipelago f12042f194 docs: track X250 kiosk Bitcoin version selector regression 2026-09-30 07:01:57 -04:00
archipelago e7cf336665 chore: publish release v1.8.21-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-09-30 05:55:13 -04:00
archipelago 8ca20de82e release: prepare signed 1.8.21-alpha OTA 2026-09-30 05:51:16 -04:00
archipelago 1fa654cb6a docs: record 1.8.21 artifact and two-node release acceptance 2026-09-30 05:39:26 -04:00
archipelago c993d9dd0d fix(lnd): require observed Bitcoin lifecycle change before dependency restart 2026-09-30 05:16:17 -04:00
archipelago 33d2b3ce60 fix(containers): preserve graceful shutdown through Quadlet and prepare 1.8.21 2026-09-30 04:59:30 -04:00
archipelago c7ce35bd43 chore: publish release v1.8.20-alpha
Demo images / Build & push demo images (push) Failing after 1m31s
2026-09-30 04:32:43 -04:00
archipelago ad1d71a462 Prepare signed v1.8.20-alpha release and record operator acceptance 2026-09-30 04:27:02 -04:00
ssmithxandClaude Opus 5.5 33477f284b fix(files): file purchased content into FileBrowser folders again
Every paid download logged "filing into filebrowser/Music/... failed
(non-fatal): Permission denied". The purchase played in-app but never
appeared in Files. FileBrowser's folders belong to its rootless container
range (host uid 100000, mode 755). This service is host uid 1000, outside
that range, so it can read them but not create files in them.

New container::filebrowser::save_new_file:
- Writes directly when the folder allows it.
- Otherwise writes through `podman unshare`, where that uid range is
  ours: to a temp file, then chowned to the folder's owner, set to 0644,
  and hard-linked into place. FileBrowser never sees a partial file and an
  existing file is never replaced. A missing folder is created and given
  its parent's owner. No sudo.
- Keeps the "name (2).ext" de-duplication the RPC did inline.

Checked the unshare script on amishparadise in a scratch folder owned
like FileBrowser's: new folder + file OK, owner/mode right, no clobber,
no temp file left, and the service can read the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:36:31 +00:00
ssmithxandClaude Opus 5.5 03e38d1ca3 test: regression tests for the paid-download fixes
- mint_client: a stub mint shows swap() sends the full v2 keyset id when
  given a cashuB short id, and leaves complete v1/v2 ids unchanged.
- fips::dial: the single-delivery decisions are now small functions
  (fips_answer_is_final, fips_retryable). Tests cover them and, against a
  silent local peer, check that a single-delivery request isn't resent
  after a timeout while an ordinary one still is.
- content_server: an unreadable paid file returns Unavailable before the
  payment gate runs, and a readable one still returns 402. Also covers
  ensure_readable's grant/reopen behaviour. The podman grant is replaced
  by a refusal under cfg(test) so results don't depend on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:12:16 +00:00
archipelago bded929812 Record validated OTA candidate and remaining release gates 2026-09-29 15:47:22 -04:00
archipelago 3612458e86 Handle empty recorded calls in install regression assertion 2026-09-29 15:38:49 -04:00
archipelago 8d9fad1749 Require Bitcoin version and pruning selection from the App Store 2026-09-29 15:37:05 -04:00
archipelago d25ed492c9 Keep Bitcoin pruning explanation below desktop install controls 2026-09-29 15:27:00 -04:00
archipelago 1f9abefc35 Isolate backend tests from live node wallets and services 2026-09-29 15:15:51 -04:00
archipelago b634f41a1c Complete paid-file caching and deliver LND waiting UI to existing nodes 2026-09-29 14:59:08 -04:00
archipelago 0f85f588fb Fix Cashu file redemption and Bitcoin-dependent wallet readiness 2026-09-29 14:42:44 -04:00
ssmithxandClaude Opus 5.5 e5fc99d66c fix(content): never charge for a file the seller can't serve or replay a spent token
After the keyset-id fix, a Minibits paid download still failed and the
buyer lost the sats. What happened, 2026-09-29, amishparadise:

1. The seller redeemed the token, then failed to read the file. It was a
   FileBrowser upload owned by the container subuid (100999) with mode
   0640. The handler mapped that Err to 404.
2. The buyer's FIPS dial treats 404 as "fall back to Tor" and resent the
   request with the same, now spent, token. The seller answered 402, and
   the buyer showed "seller doesn't accept your Cashu mint".

Fixes:
- serve_content checks the file is readable before the paid gate. If it
  isn't, it grants read with `podman unshare chmod a+r`, which matches
  the other shared files. If that also fails it returns Unavailable (503)
  without taking payment.
- The content handler returns 500 on internal errors and logs them,
  instead of a silent 404.
- New PeerRequest::single_delivery(), used for the paid download: the
  FIPS answer is final, FIPS retries only when it never connected, and
  there's no Tor replay once the request may have been delivered.
- The buyer shows the seller's error text for non-402 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:42:20 +00:00
ssmithxandClaude Opus 5.5 8b74803290 fix(ecash): repair short v2 keyset ids on every swap, not just receive
Paid cloud downloads paid with Minibits ecash were always rejected. The
buyer sends a cashuB token, which carries NUT-02 v2 keyset ids in their
8-byte short form. Minibits rotated its active keyset to a v2 id, and the
seller's verify_and_receive_payment called MintClient::swap directly,
skipping the short->full id repair that only receive_token applied. The
mint answered 422 ("ID length invalid"). The buyer then showed the
misleading "seller doesn't accept your Cashu mint" hint.

Move the repair into swap() so every caller is covered: payment verify,
streaming gate, send change, and cross-mint swaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:47:23 +00:00
archipelago 540639d2c1 chore: publish release v1.8.19-alpha 2026-09-28 13:21:51 -04:00
archipelago 562871b1ce chore: prepare signed release v1.8.19-alpha 2026-09-28 13:18:34 -04:00
archipelago cca3f8bfcd docs: include AIUI packaging fix in v1.8.19 release notes
Demo images / Build & push demo images (push) Failing after 44s
2026-09-28 13:13:31 -04:00
archipelago 89c08be712 fix(aiui): match host color scheme for iframe transparency
Demo images / Build & push demo images (push) Failing after 56s
2026-09-28 12:37:40 -04:00
archipelago b4ecf86c13 chore: stage v1.8.19-alpha version bump 2026-09-28 12:33:59 -04:00
archipelago b14fe78306 fix(aiui): expose host wallpaper and package fresh production builds 2026-09-28 12:32:18 -04:00
archipelago 3f0c1038c3 docs: add v1.8.19 release notes to settings 2026-09-28 12:23:57 -04:00
archipelago 1fbefce6df docs: add v1.8.19-alpha release notes 2026-09-28 12:23:05 -04:00
archipelago 63cb68451a fix(aiui): keep embedded chat background transparent 2026-09-22 05:04:59 -04:00
archipelago 17cfebbe26 chore: publish release v1.8.18-alpha 2026-09-20 11:49:39 -04:00
archipelago 379fb930fc chore: prepare release v1.8.18-alpha
Demo images / Build & push demo images (push) Failing after 43s
2026-09-20 11:45:35 -04:00
archipelago 1bebdeac0f Prepare v1.8.18-alpha release notes 2026-09-18 06:36:43 -04:00
archipelago f458591132 Document Minibits description customization limits
Demo images / Build & push demo images (push) Failing after 45s
2026-09-15 16:13:14 -04:00
archipelago 6155539254 Confirm Primal automatic-comment cause and successful workaround 2026-09-15 16:11:09 -04:00
archipelago 76e0f1f3b6 Trace Primal Spark auto-comment failure against Framework address 2026-09-15 16:09:16 -04:00
archipelago ba6ce2cdb6 Record live LNURL comment limit investigation 2026-09-15 16:06:21 -04:00
archipelago 8212049f57 Shorten ecash backup copy and stack card actions 2026-09-15 16:03:52 -04:00
archipelago 5814f47659 docs: verify Framework Cashu address and preserved proofs 2026-09-15 15:58:16 -04:00
archipelago 94f5e892c3 docs: track authenticated Cashu address setup and remaining verification 2026-09-15 15:47:48 -04:00
archipelago a3b6467047 fix(ecash): guide unseeded wallets through Lightning address setup 2026-09-15 15:45:32 -04:00
archipelago 66db6497ec docs: record successful Framework reboot verification 2026-09-15 15:27:13 -04:00
archipelago 81be17f09f docs: record Framework live evidence and staged fix validation 2026-09-15 15:15:34 -04:00
archipelago 4237fb5e79 fix(wallet): prioritize LND boot and reject unavailable balances 2026-09-15 15:09:08 -04:00
archipelago 4302138b4f docs: make Framework LND incident a persistent investigation blocker 2026-09-15 14:56:01 -04:00
archipelago 3b9b74dae5 chore: publish release v1.8.17-alpha
Demo images / Build & push demo images (push) Failing after 36s
2026-09-15 12:56:18 -04:00
archipelago 4021c1f496 chore: prepare release v1.8.17-alpha 2026-09-15 12:53:06 -04:00
archipelago 5f8de584bc docs: add v1.8.17-alpha release notes
Demo images / Build & push demo images (push) Failing after 42s
2026-09-15 12:33:24 -04:00
chaum 38de1b3310 Merge pull request 'fix(ecash): stop replayed Minibits claims retrying forever, reduce relay churn' (#160) from fix/minibits-already-redeemed into main 2026-09-15 16:32:53 +00:00
archipelago abfbccc906 fix(ecash): preserve retryable claims and resume relay backlogs 2026-09-15 12:31:49 -04:00
ssmithxandClaude Sonnet 5 9d4e74e094 docs: redact node hostname from the Minibits incident writeup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:19:23 +00:00
ssmithxandClaude Sonnet 5 db355b759c fix(ecash): stop replayed Minibits claims retrying forever, reduce relay churn
claim_and_redeem retried every redeem failure indefinitely, including a
terminal one: mint error 11001 "Token Already Spent" (a claim replayed by a
relay-watermark edge case, or already redeemed by an earlier run). On
archy-x250-pa3 this pinned pending_claims at 1 forever and hammered
mint.minibits.cash's swap endpoint every ~6s, with the UI permanently
showing "a payment arrived but couldn't be redeemed yet".

- mint_client: expose the NUT error-code-11001 message as
  ALREADY_REDEEMED_MSG so callers can recognize it without duplicating the
  string.
- minibits: drop (not retry) a redeem failure that matches
  is_already_redeemed — the value was already swept, so retrying can never
  succeed.
- fetch_relay_dms: query the primary relay.minibits.cash alone first,
  falling back to the public relay.damus.io/nos.lol only if it's
  unreachable, and page past a 200-DM backlog instead of silently
  stranding older DMs behind an un-advanced watermark.

This fix already existed on feat/minibits-lnurl-receive (4e410d7, 489995c,
2026-09-09) but that branch was never merged into main, which has its own
independently-diverged minibits.rs — so the bug shipped again in
1.8.16-alpha. Ported directly onto main's current implementation this time.

Immediate unblock on archy-x250-pa3: cleared the one poisoned
pending_claims entry from wallet/minibits.json by hand (already-redeemed,
zero value at risk) and restarted archipelago.service; confirmed via
journalctl that polling is quiet again.

See docs/incident-2026-09-15-minibits-already-redeemed.md for the full
writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:12:00 +00:00
archipelago 31d77f01ac chore: publish release v1.8.16-alpha
Demo images / Build & push demo images (push) Failing after 34s
2026-09-15 04:02:57 -04:00
archipelago 1b0ed281b2 chore: prepare release v1.8.16-alpha 2026-09-15 03:59:43 -04:00
archipelago 9c6580f5c0 fix: prevent stale catalog updates and redundant container recreation
Demo images / Build & push demo images (push) Failing after 40s
2026-09-15 03:40:21 -04:00
archipelago 83abb0485d fix: publish signed mempool DNS recovery catalog 2026-09-15 03:13:02 -04:00
archipelago b35409ca74 fix: recover mempool frontend after backend address changes 2026-09-15 02:49:06 -04:00
archipelago 700d39c425 fix: keep release credentials out of public remote URLs 2026-09-14 10:47:58 -04:00
archipelago 4272c47ee5 chore: sign app catalog for v1.8.15-alpha 2026-09-13 13:21:24 -04:00
archipelago c7cb043485 chore: publish release v1.8.15-alpha 2026-09-13 05:09:56 -04:00
archipelago 4dfe79290e chore: sign release v1.8.15-alpha manifest 2026-09-13 04:42:17 -04:00
archipelago d3e3df6d24 docs: add release validation note 2026-09-13 04:32:58 -04:00
archipelago 969570e38b chore: prepare release v1.8.15-alpha
Demo images / Build & push demo images (push) Successful in 3m29s
2026-09-13 04:32:16 -04:00
archipelago b73d646db5 docs: prepare 1.8.15 alpha notes
Demo images / Build & push demo images (push) Successful in 3m22s
2026-09-13 03:06:53 -04:00
archipelago 8c37ff412c fix: present Cuprate as one launchable app
Demo images / Build & push demo images (push) Successful in 3m28s
2026-09-13 03:06:22 -04:00
archipelago 06bf359535 chore: publish release v1.8.14-alpha 2026-09-13 02:56:29 -04:00
archipelago a4f3415f0f chore: prepare release v1.8.14-alpha
Demo images / Build & push demo images (push) Successful in 3m25s
2026-09-13 02:53:11 -04:00
archipelago c9c9ebe6d4 docs: sync whats new for 1.8.14 alpha
Demo images / Build & push demo images (push) Successful in 3m18s
2026-09-13 02:34:45 -04:00
archipelago 100993445b docs: prepare 1.8.14 alpha release notes 2026-09-13 02:33:30 -04:00
archipelago a4f80e7ec1 test: update GitWorkshop launcher deep-link expectation
Demo images / Build & push demo images (push) Successful in 3m14s
2026-09-13 01:48:36 -04:00
archipelago 4ad34d3a0a test: validate full Archipelago ngit promotion path 2026-09-13 01:45:22 -04:00
archipelago c9bae926a5 test: satisfy strict indexed access
Demo images / Build & push demo images (push) Successful in 3m14s
2026-09-13 01:41:11 -04:00
archipelago cb3f7e8720 Merge PR #157: Cuprate disk gate and companion dashboard
Demo images / Build & push demo images (push) Successful in 3m19s
2026-09-13 01:37:46 -04:00
archipelago eb98ebb682 Merge PR #158: preserve Bitcoin Core Tor service naming 2026-09-13 01:37:15 -04:00
archipelago 00682e6420 test: expect Cuprate launches through companion UI 2026-09-12 16:17:47 -04:00
archipelago 95cdc3daea fix: retain source port in Cuprate generated ports 2026-09-12 16:15:28 -04:00
archipelago 1d05f2c27a style: format generated app launch ports 2026-09-12 16:15:01 -04:00
archipelago b3f16d07a6 style(cuprate-ui): anchor desktop details to right edge 2026-09-12 16:14:55 -04:00
archipelago 14d2b37e99 style(cuprate-ui): right-align desktop header cards 2026-09-12 16:14:55 -04:00
archipelago f5b255ee68 style(cuprate-ui): improve mobile dashboard layout 2026-09-12 16:14:55 -04:00
archipelago 6e8d90fb5f style(cuprate-ui): match bitcoin status cards 2026-09-12 16:14:55 -04:00
archipelago 66c4b0d375 feat(cuprate-ui): add bitcoin-style dashboard tabs 2026-09-12 16:14:55 -04:00
archipelago 0f74ebfbbe feat(cuprate-ui): use app icon and shared dashboard background 2026-09-12 16:14:55 -04:00
archipelago ee11863ada refactor(cuprate-ui): align dashboard with bitcoin UI style 2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 86052d9552 refactor(cuprate): one CUPRATE_MIN_DISK_GB, manifest matches it (review)
450 existed as two independent Rust constants (RPC gates vs boot
reconciler) linked only by a "keep in lockstep" comment — updating one
would reopen the disk-fill hole. Move it to crate::constants as the
single source of truth both paths import.

Also raise apps/cuprate/manifest.yml storage dependency and disk_limit
from 300Gi to 450Gi so manifest-driven surfaces (store size, pre-checks)
show the number the gate actually enforces — a user provisioning to the
displayed 300 was refused at an unexplained 450. Catalog regenerated
(cuprate entry re-embedded; still unsigned pending sign-catalog.sh).
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 047ef98987 fix(cuprate-ui): target_height 0 means synced, not stuck (review)
Monero's get_info returns target_height == 0 when the node is FULLY
SYNCED — the field is the height being caught up to, not the chain tip.
The '??' fallback left 0 in place, so every healthy node rendered
"Syncing — 0.00%, 0 blocks behind" forever. Treat 0/absent as
target = own height, the same sentinel electrs_status.rs branches on.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago c681472e15 fix(cuprate): gate package.restart and package.update too (review)
Restart and update are stop + recreate — a fresh start by another name —
but only start carried the disk gate, so on a disk that shrank below the
floor after install, either action silently resumed the unprunable
Monero sync: the exact failure the gate exists to close.

Both now call check_cuprate_disk_compatibility after validate_app_id and
BEFORE any state mutation (user-stopped clear / Restarting / Updating
flip), matching handle_package_start's fail-clean contract.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 7c0ba14a00 feat(neode-ui): launch cuprate tiles on the Cuprate UI companion
cuprate publishes only raw JSON RPC (18090 restricted, 18183 p2p), so
launches must land on the companion on :18091, never on the running
node's runtimeUrl — same root-path special-case bitcoin uses, with the
dev vite proxy for /app/cuprate-ui/. Alias cuprate -> cuprate-ui so the
port-auth lookup finds the gated launch port on HTTPS nodes; pin the
companion icon to the cuprate mark.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago eacd74e1db feat(cuprate-ui): companion dashboard for the Cuprate Monero node
Same companion shape as bitcoin-ui/electrs-ui: host-networked nginx
bound to 127.0.0.1:18091 (auth: gated + session_passthrough), serving
a dark glass status page that polls the node's restricted RPC via a
session-gated /cuprate-rpc/ proxy — sync height/target with progress
bar, peers, mempool, chain size and free disk (from get_info), plus a
wallet 'remote node' endpoint. The offline state explains the disk gate
so a refused node says why.

No secret rendering: the restricted RPC is Monero's safe-for-public
subset, so nginx.conf is baked into the image (no pre_start hook, no
bind mount). companion.rs auto-provisions archy-cuprate-ui alongside
cuprate and reaps it when cuprate goes.

Catalog regenerated (cuprate-ui entry + manifest embed, 18091 into the
mesh launch-port list). NOTE: releases/app-catalog.json is UNSIGNED as
committed — run scripts/sign-catalog.sh before publishing.
2026-09-12 16:14:29 -04:00
ssmithxandarchipelago 34b68001d1 fix(cuprate): refuse to run on disks too small for the Monero chain
Cuprate has no pruning — verified against upstream main
(binaries/cuprated/src/config.rs): the 'pruning' crate is Monero's p2p
protocol pruning, not on-disk. Unlike the bitcoin apps, which branch on
DISK_GB in their entrypoint and self-prune, a disk-constrained cuprate
can only sync until the filesystem fills and take Archipelago down.

Translate the bitcoin disk-awareness into the only form cuprate can
honor — refuse rather than prune:
- install (sync + async RPC paths) and package.start fail with an
  actionable message below CUPRATE_MIN_DISK_GB (450 GB total: chain
  ~250 GiB + headroom; allows 500 GB-class, refuses the 250 GB VPS)
- boot reconcile skips an already-installed cuprate on a shrunken disk,
  recorded as Left("cuprate-insufficient-disk") before ensure_running
  so desired-state recovery can never undo it (same shape as
  requires-archival-bitcoin)
- df failure fail-opens at install (never block on an unreadable disk),
  fail-closes at boot (never start a doomed sync)

prod_orchestrator also registers cuprate-ui in UI_APP_IDS (its
companion commit follows).
2026-09-12 16:14:02 -04:00
archipelago 0fac51b9c5 chore: preserve signed release catalog 2026-09-12 16:00:16 -04:00
archipelago 4f0d123f27 feat: open GitWorkshop at Archipelago repository
Demo images / Build & push demo images (push) Successful in 3m33s
2026-09-12 15:57:57 -04:00
archipelago 13b1329c21 test: keep Cuprate stack as one app entry
Demo images / Build & push demo images (push) Successful in 4m0s
2026-09-12 15:33:05 -04:00
archipelago c4aa72dccc fix: route installs to apps or services
Demo images / Build & push demo images (push) Successful in 3m39s
2026-09-12 15:07:33 -04:00
archipelago d35474f774 fix: defensively hide legacy node identity
Demo images / Build & push demo images (push) Successful in 3m31s
2026-09-12 10:24:01 -04:00
archipelago a03f340bd1 fix: keep node key out of profile signer picker
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-12 10:06:41 -04:00
archipelago caaa2e729e fix: gate app launches on health readiness
Demo images / Build & push demo images (push) Successful in 3m47s
2026-09-12 09:35:25 -04:00
archipelago fbb3ada87d chore: publish release v1.8.13-alpha
Demo images / Build & push demo images (push) Successful in 3m46s
2026-09-12 06:44:01 -04:00
archipelago 72e84439ee chore: prepare release v1.8.13-alpha 2026-09-12 06:40:21 -04:00
archipelago 5081a4fe7d docs: expand v1.8.13-alpha release notes 2026-09-12 05:41:13 -04:00
archipelago 39727dacbc style: format generated app ports 2026-09-12 05:37:55 -04:00
archipelago 1e409007d4 chore: regenerate app port metadata 2026-09-12 05:05:37 -04:00
archipelago 8f144c3038 chore: remove retired AdGuard app and refresh release docs 2026-09-12 05:05:33 -04:00
archipelago 8258705df7 chore: sync v1.8.13-alpha whats new 2026-09-12 04:44:34 -04:00
archipelago d13002e022 docs: add v1.8.13-alpha release notes 2026-09-12 04:44:20 -04:00
archipelago e625b29d9e fix: route GitWorkshop installs through orchestrator 2026-09-12 04:41:14 -04:00
archipelago c4ed9fb1fa release: sign app catalog for v1.8.12-alpha 2026-09-12 04:16:21 -04:00
archipelago 2bc5e98edb chore: publish release v1.8.12-alpha
Demo images / Build & push demo images (push) Successful in 3m49s
2026-09-11 15:17:17 -04:00
archipelago c1e14f7c7a chore: prepare release v1.8.12-alpha 2026-09-11 15:13:40 -04:00
archipelago 564ffe1c47 fix(indeedhub): generate per-node encryption root 2026-09-11 11:25:44 -04:00
archipelago c34d6ef76f docs(release): finalize 1.8.12 notes
Demo images / Build & push demo images (push) Successful in 4m22s
2026-09-11 06:55:17 -04:00
archipelago dac29baf97 fix(release): surface companion build and secure GitWorkshop deps 2026-09-11 06:10:59 -04:00
archipelago ef8c3a76be chore(release): define 1.8.12 publication gates 2026-09-11 05:37:21 -04:00
ssmithxandClaude Sonnet 5 dc7b598558 fix(tor): un-alias bitcoin-core's hidden-service name; add regression tests
read_tor_address("bitcoin-core") was resolving through tor_service_name to
the shared "bitcoin" alias, but enrollment (install.rs auto-enroll and the
tor.create-service RPC) always names HiddenServiceDir/tor-hostnames entries
using the raw package_id verbatim — never canonicalized. On a real node
that's hidden_service_bitcoin-core, which the aliased lookup never found,
so the per-app UI Tor badge stayed empty even after the previous commit
made bitcoin-core auto-enrollable.

Give bitcoin-core its own identity-mapped arm instead of folding it into
the legacy bitcoin/bitcoin-knots/bitcoind alias, and pin all three lookup
tables (known_service_port, is_protocol_service, tor_service_name) with
regression tests so this alias-drift class of bug can't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 16:26:59 +00:00
ssmithxandClaude Sonnet 5 69f3a355c7 fix(tor): recognize bitcoin-core in Tor auto-enrollment tables
apps/bitcoin-core/manifest.yml uses id "bitcoin-core", but
known_service_port/is_protocol_service (tor/mod.rs) and
tor_service_name (docker_packages.rs) only matched "bitcoin" and
"bitcoin-knots", so the app silently never got auto-enrolled for a
P2P (8333) hidden service at install time, and the UI's Tor address
lookup for it always returned None.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 15:28:17 +00:00
archipelago f5c0ba85cd feat(release): stage GitWorkshop and next node updates 2026-09-09 18:15:21 -04:00
archipelago 973356df16 fix(ecash): harden Minibits claim persistence 2026-09-08 21:16:57 -04:00
e5a0d95459 fix(ecash): fetch Minibits claims from Nostr relays, not the dead /claim REST poll
Confirmed live 2026-09-08 against three real Lightning payments to a
registered @minibits.cash address: POST /claim (the only claim source
claim_and_redeem checked) always returned an empty array, no matter
how long or how often it was polled. Independently queried
wss://relay.minibits.cash and found all three payments sitting there
as NIP-04-encrypted kind-4 DMs, #p-tagged to the wallet's own Nostr
pubkey and authored by the Minibits service key — that is the actual
delivery channel for a payment made to the address, and this module
never looked at it.

fetch_relay_dms queries CLAIM_RELAY_URLS (the service's own relay plus
two public fallbacks) for kind-4 events tagged to our pubkey, feeding
matching content into the existing pending_claims retry pipeline
unchanged. A new last_dm_seen_at watermark stops the same (immutable,
never-expiring) relay event from being re-fetched and re-attempted on
every poll. The REST /claim call stays in place alongside it in case
it serves some other payment path — this only adds the missing one.

fix(ecash): trim stray whitespace before parsing a cashuA/cashuB token

Once the relay fix above surfaced the three real payments, all three
failed to redeem with "Invalid base64 in cashuB token" — the decrypted
NIP-04 content had a trailing space after the base64 payload (Minibits'
own encoding), which every base64 alphabet in decode_token_base64
rejects outright. CashuToken::deserialize now trims the whole token
string before touching the "cashuA"/"cashuB" prefix or payload. This is
a general robustness fix, not just a Minibits workaround — the same
stray-whitespace failure could hit a hand-pasted token from a clipboard
copy just as easily.

Both fixes verified end-to-end against production: all three stuck
payments (20 + 5 + 20 = 45 sats) redeemed cleanly on the first poll
after deploying this build to archy-x250-pa3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
b9862c7643 fix(ui): escape a second live vue-i18n message-compile crash + add a full-sweep test
Same class of bug as the Minibits address label
(settings.passwordNeedSpecial: "...(!@#$%^&* etc.)" — a bare @ vue-i18n
parses as linked-message syntax). This one is live in
ChangePasswordSection.vue's password-strength validator: typing a new
password with no special character throws this exact
SyntaxError the moment the message is rendered. Fixed the same way
({'@'} escaping).

Added locales/__tests__/i18nMessagesCompile.test.ts, which walks every
string in every locale file and asks the real vue-i18n compiler to
parse it — confirmed it fails on both bad strings before their fixes
and passes clean now, with no other landmines left in either locale
file. This closes the whole bug class rather than just these two
instances; a future bad interpolation string fails `npm test` instead
of only a live crash report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
6fe9c5f81b fix(ui): escape the literal @ in the Minibits address label
Root cause of "click Receive, click Ecash, the modal disappears" (in
both the browser and the Android companion's WebView, since both host
the same neode-ui bundle): vue-i18n treats a bare @ as the start of
"linked message" syntax. receiveBitcoin.lnAddressLabel ("Your
@minibits.cash address:") isn't valid linked-message syntax, so
*compiling* that message throws a SyntaxError the instant it's first
rendered — i.e. the moment wallet.ecash-lnaddress resolves and the
address section becomes visible. The uncaught render-function error
blanks the whole teleported modal, which is indistinguishable from it
just closing.

Confirmed with a real (non-mocked) Vue app + real vue-i18n compiler in
a headless Chromium — a Vitest run with `t` mocked to a no-op, which is
how the existing component test suite covers this file, cannot catch a
bad message string at all. Fixed by escaping the @ as {'@'} — the same
pattern the codebase already uses for settings.domainNamePlaceholder
("user{'@'}example.com"). Added a regression test using the real
vue-i18n instance instead of the mocked one; verified it fails on the
old string and passes on the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
28454264ac test(ui): guard the ecash-tab-click path in ReceiveBitcoinModal
Operator report (2026-09-08): clicking the Ecash tab appeared to close
the whole Receive modal. Added a regression test simulating the exact
click, both for wallet.ecash-lnaddress succeeding and failing — the
tab switch alone never emits `close` or unmounts the dialog in either
case, so this isn't reproduced by a plain component-level click; the
investigation continues with the reporter for a browser-console repro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
84b04d1634 fix(ecash): recover from a truncated/corrupt Minibits state file
archy-x250-pa3's data volume filled to 100% (cuprate at 125G, since
removed) while a client had the ecash receive tab open. save_state's
write landed mid-truncate, leaving wallet/minibits.json at 0 bytes.
load_state then hard-failed every wallet.ecash-lnaddress call with
"EOF while parsing a value", surfaced in the UI as "Lightning address
unavailable" — permanently, since nothing ever cleared the bad file.

Registration is idempotent per pubkey (re-registering returns the same
lud16 Minibits already assigned), so there's no reason a corrupt local
mirror of that state should be fatal. load_state now treats an empty
or unparseable state file the same as a missing one — re-register and
recover the same address — instead of erroring. Manually cleared the
stuck file on archy-x250-pa3 as an immediate fix; this closes the gap
so it self-heals next time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
ce5c04d49d fix(ecash): stop Minibits LN-address claims from being silently lost
A Minibits /claim response consumes the payment server-side the instant
it's returned — it can never be re-fetched. claim_and_redeem previously
decrypted/redeemed each claim inline and just warn!-logged any failure,
so a mint-unreachable blip, a stale cached server key, or an operator
who'd edited their accepted-mints list to drop the default mint (via
streaming.configure-mints) could make a real payment vanish with
nothing but a log line to show for it — claimed_count/received_sats
still came back as a clean 0, identical to "nothing arrived."

Now: every fetched claim is persisted to MinibitsState.pending_claims
before decrypt/redeem is attempted, survives failures across polls
instead of being dropped, and claim_and_redeem no longer bails out on a
fetch error without first retrying whatever was already pending.
ensure_mint_accepted self-heals the accepted-mints allow-list so the
Minibits mint (the address is inherently backed by it) can't be
excluded out from under a claim. ClaimOutcome gains failed_count,
threaded through wallet.ecash-lnaddress-claim and shown in
ReceiveBitcoinModal so a stuck claim is visible instead of silent.

Also fixes the server_nostur_pubkey field-name typo (no live state to
migrate — this feature hasn't shipped yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
ssmithxandarchipelago ce9fca1c38 feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive
The wallet used Minibits only as a Cashu mint, so the node could hold and
swap ecash there but had no addressable name at it. This derives a LUD-16
Lightning address (name@minibits.cash) from the node's own ecash wallet and
surfaces it in the ecash Receive tab above the existing paste-token box.

Identity reuses the NUT-13 ecash phrase, so there is no second secret:
  - seedHash = sha256(mnemonic.to_seed("")) — the exact hash the Minibits app
    stores, so restoring the same phrase recovers the same address both ways;
  - Nostr keys via NIP-06 at m/44'/1237'/0'/0/0 (nostr-sdk Keys::from_mnemonic,
    pinned by a unit test against the NIP-06 vector so a bump cannot silently
    move the derivation and orphan the profile).

Backend (wallet/minibits.rs) implements the verified live /v3 flow: NIP-42
challenge/verify -> JWT, idempotent /profile registration with collision
retry, and /claim polling that NIP-04-decrypts each token (service pubkey read
from the address's own LUD-16 metadata, constant fallback) and redeems it
through ecash::receive_token. Mainnet-only; state cached 0600 in
wallet/minibits.json.

New RPC: wallet.ecash-lnaddress (register-or-read, idempotent) and
wallet.ecash-lnaddress-claim (sweep Lightning payments into ecash). The modal
fetches the address on tab open, renders QR + copy, and sweeps claims while
open; a registration failure is non-fatal so paste-token still works.

Verified end-to-end against production: registered a disposable
@minibits.cash address, confirmed it resolves via /.well-known/lnurlp, and the
claim poll returns cleanly.
2026-09-08 21:16:57 -04:00
archipelago e661f237f1 fix(openwrt): harden TollGate PR integration 2026-09-08 21:06:36 -04:00
f9af30b08a feat(openwrt): make TollGate payout Lightning address configurable
Archipelago never touched /etc/tollgate/identities.json — the "owner"
payout identity was whatever the router's TollGate install happened to
default to. Confirmed live against archy-x250-pa3: an unmodified upstream
placeholder (tollgate@minibits.cash), meaning 79% of every customer payment
would auto-payout to an address the operator never chose and doesn't
control.

Adds TollGateConfig.payout_address (opt-in — None leaves the router
untouched), config::apply_payout_identity() to merge it into the "owner"
entry of identities.json without disturbing the merchant keypair or the
other profit-share identities, an RPC param on openwrt.provision-tollgate,
and a status field + reconfigure-form input in the OpenWrt Gateway panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
87a5025341 docs(tollgate-sweep): document two live-confirmed drain-CLI bugs
sweep_once() has never actually swept anything: `tollgate wallet drain
cashu` (no flags) blocks on an interactive y/N confirmation that Router::run
can never answer over a non-PTY SSH exec (empty stdin -> EOF -> defaults to
N -> "Operation cancelled." with exit code 0), so the drain_code != 0 check
can't catch it and every tick silently no-ops.

The obvious fix isn't safe either: `--json` skips the prompt, but confirmed
live against archy-x250-pa3 that on a wallet.db with a stale duplicate
per-mint entry (trailing-slash leftover from before the mint_url fix), it
completes a real swap against the good entry, then aborts on the second
(empty, stale) entry and reports "success": false without ever printing or
persisting the resulting token anywhere. 50 sats went from spendable balance
to gone in that one call. Documented so nobody "fixes" this by wiring in
--json before upstream fixes the partial-failure data loss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
2947277205 fix(openwrt): close TollGate free-access gap and mint URL mismatch
Two bugs found live against archy-x250-pa3: TollGate-3458 (the upstream
tollgate-module-basic-go installer's own default AP, rebranded from
OpenWrt's factory default wireless.default_radioN sections) was left
bound to `network=lan` — wide open, unmetered, and sharing the router's
admin LAN — because install_ipk() runs the upstream package's own
uci-defaults scripts but nothing reconciled the AP they create with the
separate `tollgate` network/bridge/firewall this project's own
provision_ssid() sets up for the "archipelago" SSID. Fixed by folding any
default_radioN section left on `lan` onto the `tollgate` network right
after it's created.

Separately, a caller-supplied mint_url with a trailing slash
(https://mint.minibits.cash/Bitcoin/) got written byte-for-byte into
accepted_mints[0].url, which tollgate-wrt string-compares exactly against
a token's embedded (slash-less) mint URL — rejecting every otherwise-valid
token as an "untrusted mint". Fixed by trimming trailing slashes before
the value is used anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
674 changed files with 99593 additions and 7018 deletions
+7 -1
View File
@@ -4,7 +4,13 @@
# Allow neode-ui (frontend + mock backend + docker configs) # Allow neode-ui (frontend + mock backend + docker configs)
!neode-ui/ !neode-ui/
# Allow demo assets (AIUI pre-built dist) !aiui/
aiui/**/node_modules
aiui/**/dist
aiui/**/.turbo
aiui/**/.build-aiui-last-*
# Allow curated demo assets
!demo/ !demo/
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*) # Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
+6 -1
View File
@@ -17,6 +17,9 @@ on:
branches: [main] branches: [main]
paths: paths:
- 'neode-ui/**' - 'neode-ui/**'
- 'aiui/**'
- 'scripts/build-aiui.sh'
- '.dockerignore'
- 'docker-compose.demo.yml' - 'docker-compose.demo.yml'
- '.gitea/workflows/demo-images.yml' - '.gitea/workflows/demo-images.yml'
workflow_dispatch: workflow_dispatch:
@@ -65,10 +68,12 @@ jobs:
push: true push: true
build-args: | build-args: |
VITE_DEMO=1 VITE_DEMO=1
SOURCE_REVISION=${{ github.sha }}
tags: | tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo ${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }} ${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy - name: Trigger Portainer redeploy
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }} # Source pushes prepare images; public deployment is an explicit post-release action.
if: ${{ success() && github.event_name == 'workflow_dispatch' && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}" run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
+6 -1
View File
@@ -17,6 +17,9 @@ on:
branches: [main] branches: [main]
paths: paths:
- 'neode-ui/**' - 'neode-ui/**'
- 'aiui/**'
- 'scripts/build-aiui.sh'
- '.dockerignore'
- 'docker-compose.demo.yml' - 'docker-compose.demo.yml'
- '.github/workflows/demo-images.yml' - '.github/workflows/demo-images.yml'
workflow_dispatch: workflow_dispatch:
@@ -65,10 +68,12 @@ jobs:
push: true push: true
build-args: | build-args: |
VITE_DEMO=1 VITE_DEMO=1
SOURCE_REVISION=${{ github.sha }}
tags: | tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo ${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }} ${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy - name: Trigger Portainer redeploy
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }} # Source pushes prepare images; public deployment is an explicit post-release action.
if: ${{ success() && github.event_name == 'workflow_dispatch' && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}" run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
+73
View File
@@ -0,0 +1,73 @@
# Blocking incident: Framework LND startup and false zero balance
Before starting or resuming work in this repository, read
`docs/incident-framework-lnd-startup.md` and check its status.
The user explicitly required this incident to take priority over later work.
While its status is OPEN:
- Surface this blocker at session start, including when the user asks for unrelated work.
- Prioritize investigation and repair on the actual Framework node. If access is
unavailable, ask for it and continue useful offline investigation; defer unrelated
work unless the user explicitly overrides this priority.
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
does not resolve the incident. Do not mark it fixed until the Framework's startup,
Receive flow, and balance behavior are verified on the node, including a controlled
reboot with access and recovery arrangements in place.
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
Never run wallet wipe/recreation as an automatic investigation or recovery step.
- Record evidence, changes, validation, and remaining work in the incident document.
This priority comes from the user's explicit instruction on 2026-09-15. It remains
in effect across sessions until the documented acceptance criteria are met or the
user explicitly changes it.
## Unit tests on a live node
Run backend unit tests through `scripts/test-backend-isolated.sh`. Do not run
unrestricted `cargo test` on a node with installed apps: older mocked-runtime
tests still reached real service commands. The runner isolates wallet data,
service buses, container storage, networking, and process IDs. Compilation with
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
## Active release regression checklist
Before resuming release work, read
`docs/post-1.8.22-regressions-20261001.md` and retain its unfinished tasks.
The operator requested that every reported issue be tracked, fixed and tested
before another OTA/ISO. Keep source/unit-test results separate from actual-node
acceptance. In particular, paid-file recovery must not send another payment,
and app cleanup must preserve wallets, persistent data and uninstall decisions.
Do not mark the new paid-file incident resolved merely because the earlier
Framework LND startup incident was closed.
## Gitea and ngit mirror parity
Nostr Git (`ngit`) is the canonical contribution and review platform. Gitea
(`origin`) mirrors accepted code on `main` and release tags. Both are required
publication mirrors; duplicate PRs and proposal branches on Gitea are not required.
For every change, including fixes and release preparation:
- Review and merge once. Push the exact same resulting commits to both mirrors;
never independently squash, rebase or merge the same change on each platform.
- Open new contributions and PRs on ngit; review and merge there, then mirror the
exact accepted main commits to Gitea. Record the ngit proposal and resulting
merge commit in the release ledger. Existing Gitea PRs must be reviewed and
explicitly linked to their ngit replacement or accepted result before closing;
do not abandon contributions or mark unmerged changes as merged. PR numbers,
reviews and discussions remain platform-specific; matching Git refs does not
prove their synchronization.
- Push main and release tags to both mirrors. Preserve commit history
and annotated tag objects/signatures. Do not resolve drift by force pushing,
deleting remote refs, or rewriting published history without explicit approval.
- After publishing source, run `python3 scripts/check-git-mirrors.py --local`.
Include each additional shared branch or release tag with repeated `--ref`
arguments (full `refs/heads/...` or `refs/tags/...` names).
- Before OTA, catalog or ISO publication, require matching reviewed local and
remote main and release tag refs, and record ngit PR dispositions in the
release acceptance ledger. A failed push, unavailable mirror, missing ref or
mismatch blocks publication; never describe a partial push as synchronized.
Run `--all` for a complete advertised branch/tag audit; a main-only pass must
never be described as full historical mirror parity. Proposal-only branches
may intentionally differ. Existing unrelated drift
must be inventoried explicitly rather than silently overwritten.
+25 -4
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app" applicationId = "com.archipelago.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 48 versionCode = 57
versionName = "0.5.28" versionName = "0.5.37"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -41,6 +41,17 @@ android {
enableV1Signing = true enableV1Signing = true
enableV2Signing = true enableV2Signing = true
} }
// Local-only UAT builds install beside both the production companion
// and its shared-key debug package. The ignored uat.keystore is made
// on the validation box; it must never be used for a public artifact.
create("uat") {
storeFile = file("uat.keystore")
storePassword = "android"
keyAlias = "androiduatkey"
keyPassword = "android"
enableV1Signing = true
enableV2Signing = true
}
} }
buildTypes { buildTypes {
@@ -51,6 +62,13 @@ android {
versionNameSuffix = "-debug" versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }
create("uat") {
initWith(getByName("debug"))
applicationIdSuffix = ".uat"
versionNameSuffix = "-uat"
signingConfig = signingConfigs.getByName("uat")
matchingFallbacks += listOf("debug")
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -118,12 +136,15 @@ tasks.register<Exec>("buildRustArm64") {
tasks.matching { tasks.matching {
it.name in listOf( it.name in listOf(
"mergeDebugNativeLibs", "mergeReleaseNativeLibs", "mergeDebugNativeLibs", "mergeUatNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders", "mergeDebugJniLibFolders", "mergeUatJniLibFolders", "mergeReleaseJniLibFolders",
) )
}.configureEach { dependsOn("buildRustArm64") } }.configureEach { dependsOn("buildRustArm64") }
dependencies { dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("org.robolectric:robolectric:4.14.1")
val composeBom = platform("androidx.compose:compose-bom:2024.05.00") val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom) implementation(composeBom)
+10 -1
View File
@@ -10,6 +10,7 @@
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. --> <!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
@@ -37,11 +38,13 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:supportsPictureInPicture="true"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:resizeableActivity="true"
android:theme="@style/Theme.Archipelago.Splash" android:theme="@style/Theme.Archipelago.Splash"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"> android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -65,6 +68,12 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".ui.screens.CompanionAudioService"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="mediaPlayback" />
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only), <!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
configured entirely by scanning the node's pairing QR. --> configured entirely by scanning the node's pairing QR. -->
<service <service
@@ -9,11 +9,18 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.archipelago.app.ui.navigation.AppNavHost import com.archipelago.app.ui.navigation.AppNavHost
import com.archipelago.app.ui.screens.releaseKioskWebView import com.archipelago.app.ui.screens.finishKioskActivity
import com.archipelago.app.ui.theme.ArchipelagoTheme import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
internal var cloudVideoPip: com.archipelago.app.ui.screens.CloudVideoPip? = null
override fun onPictureInPictureModeChanged(active: Boolean, config: android.content.res.Configuration) {
super.onPictureInPictureModeChanged(active, config)
cloudVideoPip?.modeChanged(active)
}
override fun onStop() { cloudVideoPip?.stopped(); super.onStop() }
// Pairing deep link (archipelago://pair?...) from the launch intent or a // Pairing deep link (archipelago://pair?...) from the launch intent or a
// later one (launchMode=singleTask). Consumed by AppNavHost. // later one (launchMode=singleTask). Consumed by AppNavHost.
@@ -49,11 +56,8 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
// Swiped out of recents (or otherwise finished) — let go of the // Keep an authorized playing WebView owned by the media service;
// retained kiosk WebView so the next launch starts clean. Without // discard ordinary dashboard state when the task is finished.
// this the FIPS service keeps the process (and the static WebView) if (isFinishing) finishKioskActivity()
// alive, and "close the app" no longer restarted it. isFinishing
// keeps config changes (rotation) on the fast reattach path.
if (isFinishing) releaseKioskWebView()
} }
} }
@@ -74,6 +74,7 @@ import androidx.compose.ui.unit.sp
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.screens.restartCompanionApp import com.archipelago.app.ui.screens.restartCompanionApp
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextMuted
@@ -252,6 +253,7 @@ private fun MenuPanel(
HubPage.FIPS -> "FIPS Mesh" HubPage.FIPS -> "FIPS Mesh"
HubPage.BACKUP -> "Backup & Restore" HubPage.BACKUP -> "Backup & Restore"
HubPage.SIGNER -> "Remote Signer" HubPage.SIGNER -> "Remote Signer"
HubPage.AUDIO -> "Playback diagnostics"
HubPage.HUB -> "Menu" HubPage.HUB -> "Menu"
}, },
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
@@ -307,6 +309,7 @@ private fun MenuPanel(
onDismiss() onDismiss()
restartCompanionApp(hubContext) restartCompanionApp(hubContext)
} }
HubCard(Icons.Default.Dashboard, "Playback diagnostics", "Local background audio status") { page = HubPage.AUDIO }
val versionLabel = remember { val versionLabel = remember {
runCatching { runCatching {
hubContext.packageManager hubContext.packageManager
@@ -451,6 +454,17 @@ private fun MenuPanel(
} }
} }
HubPage.AUDIO -> {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
var report by remember { mutableStateOf(CompanionAudioDiagnostics.report(context)) }
var copied by remember { mutableStateOf(false) }
Text("Local status only. No track names, addresses, credentials, or automatic uploads.", color = TextMuted, fontSize = 12.sp)
Text(report, color = TextPrimary, fontSize = 12.sp)
MenuItem(label = "Refresh", onClick = { report = CompanionAudioDiagnostics.report(context); copied = false })
MenuItem(label = if (copied) "Copied" else "Copy report", onClick = { clipboard.setText(AnnotatedString(report)); copied = true })
}
HubPage.FIPS -> { HubPage.FIPS -> {
FipsSection(embedded = true) FipsSection(embedded = true)
} }
@@ -470,7 +484,7 @@ private fun MenuPanel(
} }
} }
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER } private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER, AUDIO }
/** Big tappable destination card for the hub page: icon + title + subtitle. */ /** Big tappable destination card for the hub page: icon + title + subtitle. */
@Composable @Composable
@@ -0,0 +1,194 @@
package com.archipelago.app.ui.screens
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.net.Uri
import android.util.Rational
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.archipelago.app.MainActivity
import org.json.JSONObject
import java.net.URI
import java.util.UUID
internal fun cloudVideoOrigin(value: String?): String? = runCatching {
val uri = URI(value ?: return null)
val scheme = uri.scheme?.lowercase() ?: return null
if (scheme !in setOf("http", "https") || uri.userInfo != null) return null
val host = uri.host?.lowercase() ?: return null
val port = uri.port.takeUnless { it == -1 || it == if (scheme == "https") 443 else 80 }
"$scheme://$host${port?.let { ":$it" } ?: ""}"
}.getOrNull()
internal fun cloudVideoSenderAllowed(currentUrl: String?, source: String, allowed: Set<String>, mainFrame: Boolean): Boolean {
val origin = cloudVideoOrigin(source)
return mainFrame && origin != null && origin in allowed && cloudVideoOrigin(currentUrl) == origin
}
/** Only the dashboard's origin-restricted main-frame channel can arm Cloud PiP.
* No URL, cookies, bearer token or second media player enters native storage. */
internal class CloudVideoPip(private val activity: MainActivity?, private val fullscreen: WebViewFullscreen) {
private class Binding(val origins: Set<String>, var owner: java.lang.ref.WeakReference<CloudVideoPip>)
companion object {
private val bindings = java.util.WeakHashMap<WebView, Binding>()
}
private var webView: WebView? = null
private var session: String? = null
private var reply: JavaScriptReplyProxy? = null
private var playing = false
private var ratio = Rational(16, 9)
private var entered = false
private var registered = false
private val action = "com.archipelago.app.CLOUD_VIDEO_PIP.${UUID.randomUUID()}"
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (!entered || intent?.action != action || intent.getStringExtra("session") != session) return
event("command", if (playing) "pause" else "play")
}
}
private fun supported() = activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) == true
private fun event(state: String, command: String? = null) {
val message = JSONObject().put("type", "event").put("session", session).put("state", state)
if (command != null) message.put("command", command)
runCatching { reply?.postMessage(message.toString()) }
}
private fun params(): PictureInPictureParams {
val owner = requireNotNull(activity)
val intent = Intent(action).setPackage(owner.packageName).putExtra("session", session)
val pending = PendingIntent.getBroadcast(owner, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val control = RemoteAction(Icon.createWithResource(owner, if (playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play),
if (playing) "Pause" else "Play", if (playing) "Pause video" else "Play video", pending)
val bounds = Rect()
val builder = PictureInPictureParams.Builder().setAspectRatio(ratio).setActions(listOf(control))
if (fullscreen.bounds(bounds)) builder.setSourceRectHint(bounds)
return builder.build()
}
fun attach(view: WebView, allowedUrls: List<String>) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return
val origins = allowedUrls.mapNotNull(::cloudVideoOrigin).toSet()
if (origins.isEmpty()) return
webView = view
val existing = bindings[view]
if (existing != null) {
// Rebind the retained document; removing/re-adding a listener would
// require a reload and strand the page's existing JS bridge.
if (existing.origins != origins) {
existing.owner.clear(); webView = null
return // The page receives a bounded unavailable response; reconnect reloads policy.
}
existing.owner = java.lang.ref.WeakReference(this)
return
}
val binding = Binding(origins, java.lang.ref.WeakReference(this))
bindings[view] = binding
WebViewCompat.addWebMessageListener(view, "ArchipelagoCloudVideo", origins,
object : WebViewCompat.WebMessageListener {
override fun onPostMessage(web: WebView, message: WebMessageCompat, sourceOrigin: Uri, isMainFrame: Boolean, proxy: JavaScriptReplyProxy) {
binding.owner.get()?.receive(web, message, sourceOrigin, isMainFrame, proxy, binding.origins)
}
})
}
private fun receive(web: WebView, message: WebMessageCompat, sourceOrigin: Uri, isMainFrame: Boolean, proxy: JavaScriptReplyProxy, origins: Set<String>) {
if (web !== webView || !cloudVideoSenderAllowed(web.url, sourceOrigin.toString(), origins, isMainFrame)) return
val raw = runCatching { message.data }.getOrNull() ?: return
if (raw.length > 2048) return
val request = runCatching { JSONObject(raw) }.getOrNull() ?: return
val id = request.optString("id")
if (!id.matches(Regex("[0-9a-f-]{36}"))) return
val response = JSONObject().put("id", id)
runCatching {
when (request.optString("action")) {
"capabilities" -> response.put("supported", supported()).put("version", 1)
"arm" -> {
check(supported()) { "Picture-in-picture is unavailable on this device." }
check(!entered) { "A video is already in picture-in-picture." }
val width = request.optInt("width", 0); val height = request.optInt("height", 0)
check(width in 1..16384 && height in 1..16384) { "Video dimensions are not ready." }
ratio = Rational(((width.toDouble() / height).coerceIn(1.0 / 2.39, 2.39) * 10000).toInt(), 10000)
session = id; reply = proxy; playing = request.optBoolean("playing", false)
response.put("session", id)
}
"enter" -> {
check(request.optString("session") == session && session != null && fullscreen.isActive) { "Open the selected Cloud video fullscreen first." }
val owner = requireNotNull(activity)
if (!registered) {
ContextCompat.registerReceiver(owner, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED)
registered = true
}
check(owner.enterPictureInPictureMode(params())) { "Picture-in-picture is disabled or unavailable. Check this app's system setting." }
entered = true
response.put("active", true)
}
"state" -> {
check(request.optString("session") == session && session != null) { "Video session changed." }
playing = request.optBoolean("playing", false)
if (entered) activity?.setPictureInPictureParams(params())
}
"release" -> {
check(request.optString("session") == session && session != null) { "Video session changed." }
reset()
}
else -> error("Unsupported Cloud video action.")
}
Unit
}.onFailure { response.put("error", it.message ?: "Picture-in-picture is unavailable.") }
proxy.postMessage(response.toString())
}
fun modeChanged(active: Boolean) {
if (active) { entered = true; event("entered") }
else if (entered) {
entered = false
event("restored")
// Restoring the viewer is not a stop request. Retire the native
// session before Chromium's hide callback can recursively reset it.
session = null; reply = null
fullscreen.hide()
}
}
fun stopped() { if (entered) { event("command", "pause"); event("closed") } }
fun reset() {
event("command", "pause")
event("closed")
session = null; reply = null
fullscreen.hide()
}
fun dispose() {
reset()
if (registered) runCatching { activity?.unregisterReceiver(receiver) }
registered = false
webView?.let { view -> bindings[view]?.takeIf { it.owner.get() === this }?.owner?.clear() }
webView = null
}
}
private fun Context.pipActivity(): MainActivity? = when(this) {
is MainActivity -> this
is ContextWrapper -> baseContext.takeIf { it !== this }?.pipActivity()
else -> null
}
@Composable
internal fun rememberCloudVideoPip(fullscreen: WebViewFullscreen): CloudVideoPip {
val owner = LocalContext.current.pipActivity()
val pip = remember(owner, fullscreen) { CloudVideoPip(owner, fullscreen) }
DisposableEffect(pip) {
owner?.cloudVideoPip = pip
onDispose { if (owner != null && owner.cloudVideoPip === pip) owner.cloudVideoPip = null; pip.dispose() }
}
return pip
}
@@ -0,0 +1,135 @@
package com.archipelago.app.ui.screens
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import android.webkit.WebView
import androidx.core.content.ContextCompat
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import org.json.JSONObject
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
/** Metadata/control only: the authorized WebView owns the stream and queue. */
internal data class CompanionAudioState(
val session: String, val sequence: Long, val title: String,
val playing: Boolean, val position: Double, val duration: Double,
val previous: Boolean, val next: Boolean, val shuffle: Boolean,
val shuffled: Boolean, val artwork: String,
) {
companion object {
fun parse(value: JSONObject): CompanionAudioState {
require(value.optInt("version") == 1 && value.getString("action") == "state")
val session = value.getString("session")
require(session.matches(Regex("[0-9a-f-]{36}")))
val sequence = value.getLong("sequence")
require(sequence >= 0 && sequence <= 9007199254740991L)
val position = value.getDouble("position"); val duration = value.getDouble("duration")
require(position.isFinite() && duration.isFinite() && duration in 0.0..604800.0 && position in 0.0..duration)
val title = value.getString("title"); require(title.length <= 512)
val artwork = value.optString("artwork", "")
require(artwork.length <= 90000 && (artwork.isEmpty() || artwork.startsWith("data:image/jpeg;base64,")))
return CompanionAudioState(session, sequence, title, value.getBoolean("playing"), position, duration,
value.optBoolean("previous"), value.optBoolean("next"), value.optBoolean("shuffle"),
value.optBoolean("shuffled"), artwork)
}
}
}
internal object CompanionAudioBridge {
private val bindings = java.util.WeakHashMap<WebView, Set<String>>()
private val retired = linkedSetOf<String>()
private var view: WebView? = null
private var origin: String? = null
private var reply: ((String) -> Unit)? = null
var state: CompanionAudioState? = null
private set
var updatedAt: Long = 0
private set
fun retains(web: WebView?) = web != null && view === web && state != null
fun attach(web: WebView, urls: List<String>) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { CompanionAudioDiagnostics.record(Event.BRIDGE_UNSUPPORTED); return }
val origins = urls.mapNotNull(::cloudVideoOrigin).toSet()
if (origins.isEmpty()) { CompanionAudioDiagnostics.record(Event.NO_APPROVED_ORIGIN); return }
val existing = bindings[web]
if (existing != null) {
if (existing != origins) { CompanionAudioDiagnostics.record(Event.ORIGIN_CHANGED); bindings[web] = emptySet(); release(web) }
return
}
bindings[web] = origins
WebViewCompat.addWebMessageListener(web, "ArchipelagoAudio", origins,
object : WebViewCompat.WebMessageListener {
override fun onPostMessage(web: WebView, message: WebMessageCompat, source: Uri, main: Boolean, proxy: JavaScriptReplyProxy) {
if (bindings[web] != origins) return
val raw = runCatching { message.data }.getOrNull() ?: return
receive(web, raw, source.toString(), main, origins) { proxy.postMessage(it) }
}
})
CompanionAudioDiagnostics.record(Event.BRIDGE_ATTACHED)
}
internal fun receive(web: WebView, raw: String, source: String, main: Boolean,
origins: Set<String>, proxy: (String) -> Unit) {
CompanionAudioDiagnostics.record(Event.MESSAGE_RECEIVED)
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) { CompanionAudioDiagnostics.record(Event.SENDER_REJECTED); return }
if (raw.length > 96000) { CompanionAudioDiagnostics.record(Event.MESSAGE_TOO_LARGE); return }
val data = runCatching { JSONObject(raw) }.getOrNull() ?: run { CompanionAudioDiagnostics.record(Event.INVALID_JSON); return }
val session = data.optString("session")
if (data.optString("action") == "release") {
if (web === view && session == state?.session) { CompanionAudioDiagnostics.record(Event.SESSION_RELEASED); terminate() }
return
}
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: run { CompanionAudioDiagnostics.record(Event.INVALID_STATE); return }
if (session in retired) { CompanionAudioDiagnostics.record(Event.RETIRED_SESSION); return }
val old = state
if (old != null && old.session == session) {
if (view !== web || incoming.sequence <= old.sequence) { CompanionAudioDiagnostics.record(Event.STALE_STATE); return }
} else {
if (!incoming.playing) { CompanionAudioDiagnostics.record(Event.IDLE_STATE); return } // Do not start a service for idle metadata.
if (old != null) { command("pause"); retire(old.session) }
}
CompanionAudioDiagnostics.record(Event.STATE_ACCEPTED)
view = web; origin = cloudVideoOrigin(source); reply = proxy
state = if (old != null && old.session == session && !data.has("artwork")) incoming.copy(artwork = old.artwork) else incoming; updatedAt = SystemClock.elapsedRealtime()
runCatching {
val service = CompanionAudioService.instance
if (service != null) service.refresh()
else {
CompanionAudioDiagnostics.record(Event.SERVICE_REQUESTED)
ContextCompat.startForegroundService(web.context.applicationContext,
Intent(web.context.applicationContext, CompanionAudioService::class.java))
}
}.onFailure {
CompanionAudioDiagnostics.record(when {
it is SecurityException -> Event.SERVICE_PERMISSION_DENIED
it.javaClass.simpleName == "ForegroundServiceStartNotAllowedException" -> Event.SERVICE_BACKGROUND_START_DENIED
else -> Event.SERVICE_REQUEST_FAILED
})
event("error", "Background playback could not start. Reopen the companion and press Play.")
command("pause"); terminate()
}
}
private fun retire(session: String) {
retired.add(session)
while (retired.size > 64) retired.remove(retired.first())
}
private fun event(type: String, value: String? = null, position: Double? = null) {
val current = state ?: return
if (cloudVideoOrigin(view?.url) != origin) { terminate(); return }
val message = JSONObject().put("version", 1).put("session", current.session).put("type", type)
if (value != null) message.put(if (type == "error") "error" else "command", value)
if (position != null) message.put("position", position)
runCatching { reply?.invoke(message.toString()) }
}
fun command(name: String, position: Double? = null) = event("command", name, position)
fun release(web: WebView) { if (view === web) { CompanionAudioDiagnostics.record(Event.PAGE_RELEASED); command("stop"); terminate() } }
fun terminate() {
state?.session?.let(::retire)
state = null; view = null; origin = null; reply = null
CompanionAudioService.instance?.finishPlayback()
releaseDetachedKioskWebView()
}
fun stop() { command("stop"); terminate() }
}
@@ -0,0 +1,47 @@
package com.archipelago.app.ui.screens
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.SystemClock
import android.webkit.WebView
/** Local, memory-only allowlisted status. Never accepts URLs, titles, IDs, or exception text. */
internal object CompanionAudioDiagnostics {
enum class Event {
BRIDGE_ATTACHED, BRIDGE_UNSUPPORTED, NO_APPROVED_ORIGIN, ORIGIN_CHANGED,
MESSAGE_RECEIVED, SENDER_REJECTED, MESSAGE_TOO_LARGE, INVALID_JSON, INVALID_STATE,
RETIRED_SESSION, STALE_STATE, IDLE_STATE, STATE_ACCEPTED, SERVICE_REQUESTED,
SERVICE_REQUEST_FAILED, SERVICE_PERMISSION_DENIED, SERVICE_BACKGROUND_START_DENIED, SERVICE_CREATED, SERVICE_STARTED, FOREGROUND_ACTIVE,
SERVICE_FINISHED, SERVICE_DESTROYED, PAGE_RELEASED, SESSION_RELEASED, HEARTBEAT_EXPIRED,
}
private val counts = linkedMapOf<Event, Long>()
private val recent = ArrayDeque<Pair<Long, Event>>()
@Synchronized fun record(event: Event) {
counts[event] = (counts[event] ?: 0) + 1
// Position updates must not displace the useful startup/failure sequence.
if (recent.lastOrNull()?.second != event && event !in setOf(Event.MESSAGE_RECEIVED, Event.STATE_ACCEPTED, Event.FOREGROUND_ACTIVE)) {
recent.addLast(SystemClock.elapsedRealtime() to event)
while (recent.size > 12) recent.removeFirst()
}
}
@Synchronized internal fun events(): String = buildString {
counts.forEach { (event, count) -> append("${event.name}: $count\n") }
append("Recent transitions (seconds since boot):\n")
recent.forEach { (at, event) -> append("${at / 1000}: ${event.name}\n") }
}
fun report(context: Context): String = buildString {
val manager = context.getSystemService(NotificationManager::class.java)
append("Companion playback diagnostics v1\n")
val app = context.packageManager.getPackageInfo(context.packageName, 0)
append("App: ${app.versionName}\n")
append("Android API: ${Build.VERSION.SDK_INT}\n")
append("WebView: ${WebView.getCurrentWebViewPackage()?.versionName ?: "unavailable"}\n")
append("Notifications enabled: ${manager.areNotificationsEnabled()}\n")
append("Audio channel importance: ${manager.getNotificationChannel("companion-audio")?.importance ?: "not created"}\n")
append("Native session: ${CompanionAudioBridge.state != null}\n")
append("Native playing: ${CompanionAudioBridge.state?.playing ?: false}\n")
append("Service present: ${CompanionAudioService.instance != null}\n")
append(events())
}
}
@@ -0,0 +1,177 @@
package com.archipelago.app.ui.screens
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.AudioManager
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.util.Base64
import androidx.core.content.ContextCompat
import com.archipelago.app.MainActivity
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
/** Foreground ownership of the existing authenticated WebView player. No stream
* URL, auth token or cookie is copied into native playback or notifications. */
class CompanionAudioService : Service() {
companion object {
internal var instance: CompanionAudioService? = null
private const val CHANNEL = "companion-audio"
private const val NOTIFICATION = 4056
}
private lateinit var media: MediaSession
private val handler = Handler(Looper.getMainLooper())
private var lastArtwork = ""
private var bitmap: Bitmap? = null
private var finishing = false
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) CompanionAudioBridge.command("pause")
}
}
private val watchdog = object : Runnable {
override fun run() {
val state = CompanionAudioBridge.state ?: return
val age = SystemClock.elapsedRealtime() - CompanionAudioBridge.updatedAt
if (age > 90000) { CompanionAudioDiagnostics.record(Event.HEARTBEAT_EXPIRED); CompanionAudioBridge.stop() }
else {
if (age > 15000) CompanionAudioBridge.command("sync")
handler.postDelayed(this, 5000)
}
}
}
override fun onCreate() {
super.onCreate(); instance = this
CompanionAudioDiagnostics.record(Event.SERVICE_CREATED)
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "Audio playback", NotificationManager.IMPORTANCE_LOW))
media = MediaSession(this, "Archipelago audio")
media.setCallback(object : MediaSession.Callback() {
override fun onPlay() = CompanionAudioBridge.command("play")
override fun onPause() = CompanionAudioBridge.command("pause")
override fun onStop() = CompanionAudioBridge.stop()
override fun onSkipToNext() { if (CompanionAudioBridge.state?.next == true) CompanionAudioBridge.command("next") }
override fun onSkipToPrevious() { if (CompanionAudioBridge.state?.previous == true) CompanionAudioBridge.command("previous") }
override fun onSeekTo(pos: Long) {
val duration = CompanionAudioBridge.state?.duration ?: return
CompanionAudioBridge.command("seek", (pos / 1000.0).coerceIn(0.0, duration))
}
override fun onCustomAction(action: String, extras: Bundle?) {
if (action == "shuffle" && CompanionAudioBridge.state?.shuffle == true) CompanionAudioBridge.command("shuffle")
}
}, handler)
media.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)
media.setSessionActivity(openPlayer())
media.isActive = true
ContextCompat.registerReceiver(this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), ContextCompat.RECEIVER_NOT_EXPORTED)
handler.postDelayed(watchdog, 5000)
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
CompanionAudioDiagnostics.record(Event.SERVICE_STARTED)
val state = CompanionAudioBridge.state
if (state == null) { finishPlayback(); return START_NOT_STICKY }
finishing = false; instance = this
if (intent?.action != null && intent.getStringExtra("session") == state.session) {
when (intent.action) {
"stop" -> CompanionAudioBridge.stop()
"play", "pause" -> CompanionAudioBridge.command(intent.action!!)
"next" -> if (state.next) CompanionAudioBridge.command("next")
"previous" -> if (state.previous) CompanionAudioBridge.command("previous")
"shuffle" -> if (state.shuffle) CompanionAudioBridge.command("shuffle")
}
}
if (!finishing) refresh()
return START_NOT_STICKY // Never reconstruct an authorized stream after process death.
}
private fun openPlayer() = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
private fun action(name: String, label: String, icon: Int, session: String): Notification.Action {
val intent = Intent(this, CompanionAudioService::class.java).setAction(name).putExtra("session", session)
val pending = PendingIntent.getService(this, name.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
return Notification.Action.Builder(icon, label, pending).build()
}
internal fun refresh() {
if (finishing) return
val state = CompanionAudioBridge.state ?: return
if (state.artwork != lastArtwork) {
lastArtwork = state.artwork
bitmap = decodeArtwork(state.artwork)
}
val metadata = MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE, state.title)
.putString(MediaMetadata.METADATA_KEY_ARTIST, "Archipelago")
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1000).toLong())
bitmap?.let { metadata.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) }
media.setMetadata(metadata.build())
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_STOP
if (state.duration > 0) actions = actions or PlaybackState.ACTION_SEEK_TO
if (state.previous) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
if (state.next) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
val playback = PlaybackState.Builder().setActions(actions)
.setState(if (state.playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
(state.position * 1000).toLong(), if (state.playing) 1f else 0f, SystemClock.elapsedRealtime())
if (state.shuffle) playback.addCustomAction("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate)
media.setPlaybackState(playback.build())
val controls = mutableListOf<Notification.Action>()
if (state.previous) controls.add(action("previous", "Previous", android.R.drawable.ic_media_previous, state.session))
controls.add(action(if (state.playing) "pause" else "play", if (state.playing) "Pause" else "Play",
if (state.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, state.session))
if (state.next) controls.add(action("next", "Next", android.R.drawable.ic_media_next, state.session))
val compact = controls.indices.toList().toIntArray()
if (state.shuffle) controls.add(action("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate, state.session))
controls.add(action("stop", "Stop", android.R.drawable.ic_menu_close_clear_cancel, state.session))
val notification = Notification.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.ic_media_play).setContentTitle(state.title).setContentText("Archipelago")
.setContentIntent(openPlayer()).setOnlyAlertOnce(true).setOngoing(state.playing)
.setVisibility(Notification.VISIBILITY_PUBLIC).setCategory(Notification.CATEGORY_TRANSPORT)
.setStyle(Notification.MediaStyle().setMediaSession(media.sessionToken).setShowActionsInCompactView(*compact))
.setActions(*controls.toTypedArray())
bitmap?.let { notification.setLargeIcon(it) }
startForeground(NOTIFICATION, notification.build())
CompanionAudioDiagnostics.record(Event.FOREGROUND_ACTIVE)
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (CompanionAudioBridge.state?.playing != true) CompanionAudioBridge.stop()
super.onTaskRemoved(rootIntent)
}
internal fun finishPlayback() {
if (finishing) return
finishing = true
CompanionAudioDiagnostics.record(Event.SERVICE_FINISHED)
if (instance === this) instance = null
stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
}
override fun onDestroy() {
CompanionAudioDiagnostics.record(Event.SERVICE_DESTROYED)
handler.removeCallbacksAndMessages(null)
runCatching { unregisterReceiver(noisy) }
media.isActive = false; media.release(); bitmap = null
if (instance === this) { instance = null; CompanionAudioBridge.stop() }
super.onDestroy()
}
}
internal fun decodeArtwork(data: String): Bitmap? = runCatching {
if (!data.startsWith("data:image/jpeg;base64,") || data.length > 90000) return null
val bytes = Base64.decode(data.substringAfter(','), Base64.NO_WRAP)
if (bytes.size < 4 || bytes[0] != 0xff.toByte() || bytes[1] != 0xd8.toByte() || bytes[2] != 0xff.toByte()) return null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth !in 1..512 || bounds.outHeight !in 1..512) return null
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}.getOrNull()
@@ -0,0 +1,179 @@
package com.archipelago.app.ui.screens
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.webkit.CookieManager
import android.webkit.DownloadListener
import android.webkit.URLUtil
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.*
import okhttp3.Call
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.io.OutputStream
import java.util.concurrent.TimeUnit
internal data class WebDownload(val url: String, val userAgent: String, val cookies: String, val name: String, val mime: String)
/** Only the starting origin receives its WebView cookies, even across redirects. */
internal fun streamWebDownload(
download: WebDownload,
output: OutputStream,
client: OkHttpClient,
onCall: (Call) -> Unit = {},
checkCancelled: () -> Unit = {},
onProgress: (Long, Long) -> Unit = { _, _ -> },
): Long {
val transport = client.newBuilder().followRedirects(false).followSslRedirects(false).build()
val original = download.url.toHttpUrlOrNull() ?: throw IOException("Unsupported download link")
var url = original
var redirects = 0
while (true) {
checkCancelled()
if (url.username.isNotEmpty() || url.password.isNotEmpty()) throw IOException("Unsupported download link")
val request = Request.Builder().url(url).header("User-Agent", download.userAgent)
if (url.scheme == original.scheme && url.host == original.host && url.port == original.port && download.cookies.isNotBlank()) {
request.header("Cookie", download.cookies)
}
val call = transport.newCall(request.build())
onCall(call)
call.execute().use { response ->
if (response.code in listOf(301, 302, 303, 307, 308)) {
if (++redirects > 5) throw IOException("Too many download redirects")
val next = response.header("Location")?.let { url.resolve(it) } ?: throw IOException("Invalid download redirect")
if (url.isHttps && !next.isHttps) throw IOException("Insecure download redirect blocked")
url = next
} else {
if (response.code == 401 || response.code == 403) throw IOException("Sign in to the node again, then retry the download")
if (!response.isSuccessful) throw IOException("Download failed (HTTP ${response.code})")
if (response.header("Content-Type")?.substringBefore(';')?.trim()?.lowercase() == "text/html" &&
download.mime != "text/html" && !download.name.endsWith(".html", true) && !download.name.endsWith(".htm", true)) {
throw IOException("Sign in to the node again, then retry the download")
}
val body = response.body ?: throw IOException("The download was empty")
val total = body.contentLength()
var written = 0L
body.byteStream().use { input ->
val buffer = ByteArray(64 * 1024)
var lastUpdate = 0L
while (true) {
checkCancelled()
val count = input.read(buffer)
if (count == -1) break
output.write(buffer, 0, count)
written += count
val now = System.nanoTime()
if (now - lastUpdate > 100_000_000L) { onProgress(written, total); lastUpdate = now }
}
}
if (total >= 0 && written != total) throw IOException("Download interrupted; please retry")
onProgress(written, total)
return written
}
}
}
}
/** Uses the system Save dialog: no broad storage permission and no external browser login. */
@Composable
internal fun rememberWebViewDownloads(): DownloadListener {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var pending by remember { mutableStateOf<WebDownload?>(null) }
var active by remember { mutableStateOf<WebDownload?>(null) }
var progress by remember { mutableStateOf<Pair<Long, Long>>(0L to -1L) }
var failure by remember { mutableStateOf<String?>(null) }
var job by remember { mutableStateOf<Job?>(null) }
val currentCall = remember { java.util.concurrent.atomic.AtomicReference<Call?>(null) }
val client = remember {
OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.connectTimeout(20, TimeUnit.SECONDS).readTimeout(60, TimeUnit.SECONDS).build()
}
fun cancel() { job?.cancel(); currentCall.getAndSet(null)?.cancel() }
DisposableEffect(Unit) { onDispose { currentCall.getAndSet(null)?.cancel() } }
val save = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val download = pending
pending = null
val uri = result.data?.data
if (result.resultCode != Activity.RESULT_OK || uri == null || download == null) return@rememberLauncherForActivityResult
job = scope.launch {
active = download
progress = 0L to -1L
var complete = false
try {
withContext(Dispatchers.IO) {
val task = currentCoroutineContext()
context.contentResolver.openOutputStream(uri, "w")?.use { output ->
streamWebDownload(download, output, client,
onCall = { call -> currentCall.set(call); if (!task.isActive) call.cancel() },
checkCancelled = { task.ensureActive() },
onProgress = { done, total -> scope.launch { progress = done to total } })
} ?: throw IOException("Unable to open the selected destination")
}
complete = true
Toast.makeText(context, "Download complete: ${download.name}", Toast.LENGTH_LONG).show()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (currentCoroutineContext().isActive) {
// Do not expose authenticated URLs or request headers in UI/logs.
failure = when {
error is javax.net.ssl.SSLException -> "The server certificate could not be verified."
error is IOException && error.message?.startsWith("Sign in") == true -> error.message
else -> "Download failed. Check your connection and available storage, then try again."
}
}
} finally {
currentCall.getAndSet(null)?.cancel()
if (!complete) withContext(NonCancellable + Dispatchers.IO) {
// This URI was newly created by ACTION_CREATE_DOCUMENT; never remove an existing user file.
runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) }
}
active = null
job = null
}
}
}
if (active != null) {
AlertDialog(onDismissRequest = {}, title = { Text("Downloading") }, text = {
Column {
Text(active!!.name)
if (progress.second > 0) LinearProgressIndicator(progress = (progress.first.toFloat() / progress.second).coerceIn(0f, 1f))
else LinearProgressIndicator()
}
}, confirmButton = {}, dismissButton = { TextButton(onClick = { cancel() }) { Text("Cancel") } })
}
failure?.let { message ->
AlertDialog(onDismissRequest = { failure = null }, title = { Text("Download unavailable") },
text = { Text(message) }, confirmButton = { TextButton(onClick = { failure = null }) { Text("OK") } })
}
return DownloadListener { url, userAgent, disposition, mimeType, _ ->
if (active != null || pending != null) {
Toast.makeText(context, "Finish or cancel the current download first", Toast.LENGTH_SHORT).show()
} else if (url.toHttpUrlOrNull() == null) {
failure = "This download link is not supported. Open the file from Cloud and try again."
} else {
val mime = mimeType?.substringBefore(';')?.takeIf { it.contains('/') } ?: "application/octet-stream"
val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[\\\\/\\p{Cntrl}]"), "_").take(180).ifBlank { "download" }
pending = WebDownload(url, userAgent ?: "Archipelago Companion", CookieManager.getInstance().getCookie(url).orEmpty(), name, mime)
try {
save.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE); type = mime; putExtra(Intent.EXTRA_TITLE, name)
})
} catch (_: Exception) { pending = null; failure = "No file-saving app is available on this device." }
}
}
}
@@ -0,0 +1,117 @@
package com.archipelago.app.ui.screens
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Color
import android.view.View
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
private fun Context.fullscreenActivity(): ComponentActivity? = when (this) {
is ComponentActivity -> this
is ContextWrapper -> baseContext.takeIf { it !== this }?.fullscreenActivity()
else -> null
}
/** Hosts Chromium's custom fullscreen view without replacing or reloading its WebView. */
internal class WebViewFullscreen(private val activity: ComponentActivity?) {
private var overlay: FrameLayout? = null
private var callback: WebChromeClient.CustomViewCallback? = null
private var back: OnBackPressedCallback? = null
val isActive: Boolean get() = overlay != null
fun bounds(rect: android.graphics.Rect): Boolean = overlay?.getGlobalVisibleRect(rect) == true
private var visibleBars = 0
private var originalBehavior = 0
fun show(view: View?, onHidden: WebChromeClient.CustomViewCallback?) {
val owner = activity
// A second enter must not detach the active video or strand its callback.
if (owner == null || owner.isFinishing || owner.isDestroyed || view == null ||
view.parent != null || overlay != null
) {
onHidden?.onCustomViewHidden()
return
}
val decor = owner.window.decorView as? ViewGroup
if (decor == null) { onHidden?.onCustomViewHidden(); return }
val controller = WindowCompat.getInsetsController(owner.window, decor)
val insets = ViewCompat.getRootWindowInsets(decor)
visibleBars = 0
if (insets?.isVisible(WindowInsetsCompat.Type.statusBars()) != false) {
visibleBars = visibleBars or WindowInsetsCompat.Type.statusBars()
}
if (insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) != false) {
visibleBars = visibleBars or WindowInsetsCompat.Type.navigationBars()
}
originalBehavior = controller.systemBarsBehavior
val host = FrameLayout(owner).apply {
setBackgroundColor(Color.BLACK)
keepScreenOn = true
addView(view, FrameLayout.LayoutParams(-1, -1))
}
overlay = host
callback = onHidden
decor.addView(host, ViewGroup.LayoutParams(-1, -1))
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
back = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = hide()
}.also { owner.onBackPressedDispatcher.addCallback(it) }
view.requestFocus()
}
fun hide() {
val host = overlay ?: return
if (activity?.isInPictureInPictureMode == true) {
// A navigation/logout/custom-view exit must never expose the node
// management UI in the small OS window. Keep a black cover until
// the activity leaves PiP; the ordinary hide then removes it.
val notify = callback; callback = null
back?.remove(); back = null
host.keepScreenOn = false; host.removeAllViews()
host.addView(android.widget.TextView(host.context).apply {
text = "Video paused. Expand to return."
setTextColor(Color.WHITE)
gravity = android.view.Gravity.CENTER
contentDescription = "Video paused. Use picture-in-picture controls to expand or close."
}, FrameLayout.LayoutParams(-1, -1))
notify?.onCustomViewHidden()
return
}
// Clear first: Chromium may synchronously call onHideCustomView again.
overlay = null
val notify = callback
callback = null
back?.remove()
back = null
host.keepScreenOn = false
host.removeAllViews()
(host.parent as? ViewGroup)?.removeView(host)
activity?.let { owner ->
val controller = WindowCompat.getInsetsController(owner.window, owner.window.decorView)
controller.systemBarsBehavior = originalBehavior
controller.hide(WindowInsetsCompat.Type.systemBars())
if (visibleBars != 0) controller.show(visibleBars)
}
notify?.onCustomViewHidden()
}
}
@Composable
internal fun rememberWebViewFullscreen(): WebViewFullscreen {
val context = LocalContext.current
val fullscreen = remember(context) { WebViewFullscreen(context.fullscreenActivity()) }
DisposableEffect(fullscreen) { onDispose { fullscreen.hide() } }
return fullscreen
}
@@ -144,6 +144,29 @@ private fun openExternalUrl(context: android.content.Context, url: String) {
* this when the task is genuinely finishing. */ * this when the task is genuinely finishing. */
fun releaseKioskWebView() = KioskWebView.drop() fun releaseKioskWebView() = KioskWebView.drop()
/** A playing session is owned by the foreground media service after task close. */
fun finishKioskActivity() {
val view = KioskWebView.instance ?: return
if (!CompanionAudioBridge.retains(view)) { KioskWebView.drop(); return }
(view.parent as? ViewGroup)?.removeView(view)
KioskWebView.backgroundOwned = true
KioskWebView.clearDelegates()
view.setOnTouchListener(null)
view.setOnApplyWindowInsetsListener(null)
view.setDownloadListener(null)
view.webChromeClient = null
view.webViewClient = object : WebViewClient() {
override fun onPageStarted(web: WebView?, url: String?, favicon: Bitmap?) {
web?.let { CompanionAudioBridge.release(it) }
}
}
(view.context as? android.content.MutableContextWrapper)?.baseContext = view.context.applicationContext
}
internal fun releaseDetachedKioskWebView() {
if (KioskWebView.backgroundOwned && !CompanionAudioBridge.retains(KioskWebView.instance)) KioskWebView.drop()
}
/** Restart the app in place: throw away the retained page and relaunch the /** Restart the app in place: throw away the retained page and relaunch the
* task from scratch. The mesh/VPN service is deliberately left running — this * task from scratch. The mesh/VPN service is deliberately left running — this
* is the "give me a clean app" button (hub menu), not a process kill. */ * is the "give me a clean app" button (hub menu), not a process kill. */
@@ -294,6 +317,7 @@ private fun isSameHost(url: String, base: String): Boolean {
data class InAppLaunch(val url: String, val icon: String? = null, val name: String? = null) data class InAppLaunch(val url: String, val icon: String? = null, val name: String? = null)
private object KioskWebView { private object KioskWebView {
var backgroundOwned = false
var instance: WebView? = null var instance: WebView? = null
var url: String? = null var url: String? = null
@@ -306,13 +330,19 @@ private object KioskWebView {
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> } var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
var onQrClose: () -> Unit = {} var onQrClose: () -> Unit = {}
fun clearDelegates() {
onRouteOutbound = {}; onOpenInApp = {}; onQrOpen = {}
onQrStatus = { _, _ -> }; onQrClose = {}
}
fun drop() { fun drop() {
instance?.let { val old = instance
instance = null; url = null; backgroundOwned = false
clearDelegates()
old?.let {
CompanionAudioBridge.release(it)
(it.parent as? ViewGroup)?.removeView(it) (it.parent as? ViewGroup)?.removeView(it)
it.destroy() it.destroy()
} }
instance = null
url = null
} }
} }
@@ -326,8 +356,9 @@ private object KioskWebView {
private fun injectSafeAreaVars(view: WebView) { private fun injectSafeAreaVars(view: WebView) {
val insets = view.rootWindowInsets ?: return // listener re-fires when real val insets = view.rootWindowInsets ?: return // listener re-fires when real
val density = view.resources.displayMetrics.density val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt() val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt() val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
val sab = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom / density).toInt()
// The insets listener fires on every pass (every IME show/hide); skip the // The insets listener fires on every pass (every IME show/hide); skip the
// JS round-trip — and the Vue event it dispatches — when nothing changed. // JS round-trip — and the Vue event it dispatches — when nothing changed.
val stamp = "sa:$sat,$sab" val stamp = "sa:$sat,$sab"
@@ -377,7 +408,8 @@ private fun injectSafeAreaVars(view: WebView) {
private fun injectTopInset(view: WebView) { private fun injectTopInset(view: WebView) {
val insets = view.rootWindowInsets ?: return val insets = view.rootWindowInsets ?: return
val density = view.resources.displayMetrics.density val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt() val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
if (sat <= 0) return if (sat <= 0) return
view.evaluateJavascript( view.evaluateJavascript(
""" """
@@ -609,6 +641,9 @@ fun WebViewScreen(
// before surfacing the error page: the mesh tunnel works from anywhere. // before surfacing the error page: the mesh tunnel works from anywhere.
meshFallbackUrl: String? = null, meshFallbackUrl: String? = null,
) { ) {
val fullscreen = rememberWebViewFullscreen()
val cloudPip = rememberCloudVideoPip(fullscreen)
val downloads = rememberWebViewDownloads()
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
// First kiosk load (often over the FIPS mesh) gets the full branded // First kiosk load (often over the FIPS mesh) gets the full branded
// loader; later navigations keep just the slim top progress bar. // loader; later navigations keep just the slim top progress bar.
@@ -896,7 +931,9 @@ fun WebViewScreen(
// stale closures from the previous visit are replaced. // stale closures from the previous visit are replaced.
if (KioskWebView.url != serverUrl) KioskWebView.drop() if (KioskWebView.url != serverUrl) KioskWebView.drop()
val reused = KioskWebView.instance val reused = KioskWebView.instance
(reused ?: WebView(context)).apply { (reused ?: WebView(android.content.MutableContextWrapper(context))).apply {
(this.context as? android.content.MutableContextWrapper)?.baseContext = context
KioskWebView.backgroundOwned = false
(parent as? ViewGroup)?.removeView(this) (parent as? ViewGroup)?.removeView(this)
layoutParams = ViewGroup.LayoutParams( layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -911,6 +948,9 @@ fun WebViewScreen(
cookieManager.setAcceptThirdPartyCookies(this, true) cookieManager.setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings() applyArchipelagoSettings()
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
CompanionAudioBridge.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
setDownloadListener(downloads)
settings.apply { settings.apply {
setSupportMultipleWindows(true) // enables onCreateWindow for window.open setSupportMultipleWindows(true) // enables onCreateWindow for window.open
// Let JS open windows without a synchronous user-gesture // Let JS open windows without a synchronous user-gesture
@@ -991,6 +1031,51 @@ fun WebViewScreen(
) )
} }
} }
/** HTML downloads are not handled by WebView.
* Fetch only this connected node's public CA
* over its always-available HTTP listener,
* verify it is an actual CA certificate, then
* hand it to Android's trusted system prompt.
* No caller-controlled certificate bytes are
* accepted by this bridge. */
@android.webkit.JavascriptInterface
fun installNodeCertificate() {
scope.launch {
try {
val der = withContext(Dispatchers.IO) {
val host = android.net.Uri.parse(serverUrl).host
?: error("node URL has no host")
val caUrl = java.net.URI(
"http", null, host, 80, "/ca.crt", null, null,
).toASCIIString()
val request = okhttp3.Request.Builder().url(caUrl).build()
okhttp3.OkHttpClient().newCall(request).execute().use { response ->
if (!response.isSuccessful) error("CA download failed")
val bytes = response.body?.bytes() ?: error("empty CA")
if (bytes.size > 64 * 1024) error("CA is too large")
val cert = java.security.cert.CertificateFactory
.getInstance("X.509")
.generateCertificate(java.io.ByteArrayInputStream(bytes))
as java.security.cert.X509Certificate
if (cert.basicConstraints < 0) error("certificate is not a CA")
cert.encoded
}
}
val intent = android.security.KeyChain.createInstallIntent().apply {
putExtra(android.security.KeyChain.EXTRA_CERTIFICATE, der)
putExtra(
android.security.KeyChain.EXTRA_NAME,
"Archipelago node CA",
)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {
// Network failure, invalid CA, or no credential installer.
}
}
}
}, },
"ArchipelagoNative", "ArchipelagoNative",
) )
@@ -1039,6 +1124,8 @@ fun WebViewScreen(
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
CompanionAudioBridge.release(view ?: return)
cloudPip.reset()
isLoading = true isLoading = true
hasError = false hasError = false
// New document — the injected safe-area style is // New document — the injected safe-area style is
@@ -1134,6 +1221,12 @@ fun WebViewScreen(
} }
webChromeClient = object : WebChromeClient() { webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
fullscreen.show(view, callback)
}
override fun onHideCustomView() { cloudPip.reset(); fullscreen.hide() }
override fun onProgressChanged(view: WebView?, newProgress: Int) { override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadProgress = newProgress loadProgress = newProgress
} }
@@ -1499,6 +1592,8 @@ private fun InAppBrowser(
appName: String? = null, appName: String? = null,
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val fullscreen = rememberWebViewFullscreen()
val downloads = rememberWebViewDownloads()
val context = LocalContext.current val context = LocalContext.current
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the // Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
// kiosk's isSameNode; a mismatch here bounced app links to the browser. // kiosk's isSameNode; a mismatch here bounced app links to the browser.
@@ -1523,6 +1618,11 @@ private fun InAppBrowser(
var loaderIcon by remember { mutableStateOf<Bitmap?>(null) } var loaderIcon by remember { mutableStateOf<Bitmap?>(null) }
var progress by remember { mutableIntStateOf(0) } var progress by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
// Once this WebView has painted an app, keep that surface visible during
// same-app reloads/navigation. Covering every navigation with an opaque
// Compose loader caused GitWorkshop to flash, and an IndeeHub auth reload
// could remain covered when WebView omitted the final callback.
var hasCommittedPage by remember { mutableStateOf(false) }
var canGoBack by remember { mutableStateOf(false) } var canGoBack by remember { mutableStateOf(false) }
var canGoForward by remember { mutableStateOf(false) } var canGoForward by remember { mutableStateOf(false) }
// Main-frame load failure — the branded offline screen renders instead of // Main-frame load failure — the branded offline screen renders instead of
@@ -1591,11 +1691,32 @@ private fun InAppBrowser(
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings() applyArchipelagoSettings()
setDownloadListener(downloads)
// Node apps (BTCPay invoices, LND, Portainer tokens) are // Node apps (BTCPay invoices, LND, Portainer tokens) are
// served over plain HTTP too — same dead-clipboard trap. // served over plain HTTP too — same dead-clipboard trap.
addClipboardBridge() addClipboardBridge()
val appBrowserView = this
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun expectPageTransition() {
appBrowserView.post {
hasCommittedPage = false
loading = true
appBrowserView.invalidate()
}
}
},
"ArchipelagoSurface",
)
webChromeClient = object : WebChromeClient() { webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
fullscreen.show(view, callback)
}
override fun onHideCustomView() = fullscreen.hide()
override fun onProgressChanged(view: WebView?, newProgress: Int) { override fun onProgressChanged(view: WebView?, newProgress: Int) {
progress = newProgress progress = newProgress
} }
@@ -1623,7 +1744,7 @@ private fun InAppBrowser(
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true loading = !hasCommittedPage
loadError = false loadError = false
view?.let { view?.let {
injectTopInset(it) injectTopInset(it)
@@ -1632,6 +1753,7 @@ private fun InAppBrowser(
} }
override fun onPageFinished(view: WebView?, u: String?) { override fun onPageFinished(view: WebView?, u: String?) {
hasCommittedPage = true
loading = false loading = false
canGoBack = view?.canGoBack() == true canGoBack = view?.canGoBack() == true
canGoForward = view?.canGoForward() == true canGoForward = view?.canGoForward() == true
@@ -1641,6 +1763,14 @@ private fun InAppBrowser(
} }
} }
override fun onPageCommitVisible(view: WebView?, url: String?) {
// Fires when the new main-frame pixels are ready,
// earlier and more reliably than onPageFinished
// for service-worker-controlled SPAs.
hasCommittedPage = true
loading = false
}
override fun onReceivedError( override fun onReceivedError(
view: WebView?, view: WebView?,
request: WebResourceRequest?, request: WebResourceRequest?,
@@ -1732,6 +1862,7 @@ private fun InAppBrowser(
text = stringResource(R.string.retry), text = stringResource(R.string.retry),
onClick = { onClick = {
loadError = false loadError = false
hasCommittedPage = false
loading = true loading = true
browser?.reload() browser?.reload()
}, },
@@ -0,0 +1,26 @@
package com.archipelago.app.ui.screens
import org.junit.Assert.*
import org.junit.Test
class CloudVideoPipTest {
@Test fun nativeChannelRejectsSiblingFrameAndStaleOrForeignPage() {
val allowed = setOf("https://node.test", "http://[fd00::1]")
assertTrue(cloudVideoSenderAllowed("https://node.test/cloud", "https://node.test", allowed, true))
assertFalse(cloudVideoSenderAllowed("https://node.test/cloud", "https://node.test", allowed, false))
assertFalse(cloudVideoSenderAllowed("https://other.test", "https://node.test", allowed, true))
assertFalse(cloudVideoSenderAllowed("https://node.test:7778", "https://node.test:7778", allowed, true))
assertFalse(cloudVideoSenderAllowed("https://node.test", "http://node.test", allowed, true))
}
@Test fun exactOriginIncludesSchemeAndNonDefaultPort() {
assertEquals("https://node.test", cloudVideoOrigin("https://NODE.test:443/cloud"))
assertEquals("http://node.test:8080", cloudVideoOrigin("http://node.test:8080/cloud?file=video"))
assertEquals("http://[fd00::1]", cloudVideoOrigin("http://[fd00::1]/cloud"))
assertNotEquals(cloudVideoOrigin("https://node.test"), cloudVideoOrigin("http://node.test"))
assertNotEquals(cloudVideoOrigin("https://node.test"), cloudVideoOrigin("https://node.test:7778"))
}
@Test fun unsupportedAndCredentialOriginsCannotReceiveBridge() {
for (url in listOf("javascript:alert(1)", "file:///video", "data:text/plain,video", "https://user:password@node.test/video", "not-a-url")) assertNull(cloudVideoOrigin(url))
}
}
@@ -0,0 +1,157 @@
package com.archipelago.app.ui.screens
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import org.junit.Before
import org.robolectric.Shadows
import org.robolectric.RuntimeEnvironment
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [28, 35])
class CompanionAudioTest {
@Before fun compatReceiverPermission() {
val app = RuntimeEnvironment.getApplication()
// The real merged manifest contributes this AndroidX permission.
Shadows.shadowOf(app).grantPermissions(app.packageName + ".DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
}
@Test fun actualBridgeBindsOriginSessionAndSequenceAndReleasesStoppedPlayback() {
val app = RuntimeEnvironment.getApplication()
val web = android.webkit.WebView(app)
web.loadUrl("https://node.test/cloud")
val events = mutableListOf<JSONObject>()
fun send(message: JSONObject, origin: String = "https://node.test", main: Boolean = true) {
CompanionAudioBridge.receive(web, message.toString(), origin, main, setOf("https://node.test")) { events.add(JSONObject(it)) }
}
try {
send(state(), main = false); assertNull(CompanionAudioBridge.state)
send(state(), origin = "https://foreign.test"); assertNull(CompanionAudioBridge.state)
send(state()); assertTrue(CompanionAudioBridge.retains(web))
send(state().put("sequence", 0).put("playing", false)); assertTrue(CompanionAudioBridge.state!!.playing)
CompanionAudioBridge.command("seek", 32.0)
assertEquals("seek", events.last().getString("command")); assertEquals(32.0, events.last().getDouble("position"), 0.0)
send(state().put("sequence", 2).put("playing", false)); assertFalse(CompanionAudioBridge.state!!.playing)
CompanionAudioBridge.stop(); assertNull(CompanionAudioBridge.state)
assertEquals("stop", events.last().getString("command"))
send(state().put("sequence", 3)); assertNull(CompanionAudioBridge.state) // delayed state cannot revive a stopped session
} finally { CompanionAudioBridge.release(web); web.destroy() }
}
@Test fun liveBridgeBuildsForegroundMediaNotificationForSameSession() {
val app = RuntimeEnvironment.getApplication()
val web = android.webkit.WebView(app); web.loadUrl("https://node.test/cloud")
val payload = state().put("session", "22345678-1234-1234-1234-123456789abc")
CompanionAudioBridge.receive(web, payload.toString(), "https://node.test", true, setOf("https://node.test")) {}
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
try {
lifecycle.get().onStartCommand(null, 0, 1)
val notification = Shadows.shadowOf(lifecycle.get()).lastForegroundNotification
assertNotNull(notification)
assertEquals("Current song", notification.extras.getString(android.app.Notification.EXTRA_TITLE))
assertEquals(5, notification.actions.size)
assertNotNull(notification.extras.getParcelable<android.media.session.MediaSession.Token>(android.app.Notification.EXTRA_MEDIA_SESSION))
lifecycle.get().onTaskRemoved(null)
assertTrue(CompanionAudioBridge.retains(web))
} finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
}
@Test
@Config(shadows = [RecordingAudioMediaSession::class])
fun jpegArtworkReachesNotificationAndMediaDescription() {
// Real 16x16 JPEG generated by Chromium canvas, independent of Android bitmap shadows.
val artwork = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAQABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AJVAA//Z"
assertNotNull(decodeArtwork(artwork))
val app = RuntimeEnvironment.getApplication()
val web = android.webkit.WebView(app); web.loadUrl("https://node.test/cloud")
val payload = state().put("session", "32345678-1234-1234-1234-123456789abc").put("artwork", artwork)
CompanionAudioBridge.receive(web, payload.toString(), "https://node.test", true, setOf("https://node.test")) {}
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
try {
lifecycle.get().onStartCommand(null, 0, 1)
val notification = Shadows.shadowOf(lifecycle.get()).lastForegroundNotification
assertNotNull(notification.getLargeIcon())
// Robolectric's MediaController does not read MediaSession metadata;
// capture the actual service's setMetadata call instead.
val metadata = RecordingAudioMediaSession.metadata!!
assertNotNull(metadata.getBitmap(android.media.MediaMetadata.METADATA_KEY_ALBUM_ART))
assertNotNull(metadata.description.iconBitmap)
// Position-only refresh must retain the same thumbnail.
CompanionAudioBridge.receive(web, state().put("session", payload.getString("session")).put("sequence", 2).toString(),
"https://node.test", true, setOf("https://node.test")) {}
assertNotNull(Shadows.shadowOf(lifecycle.get()).lastForegroundNotification.getLargeIcon())
// A following song without art must not keep the previous cover.
CompanionAudioBridge.receive(web, state().put("session", payload.getString("session")).put("sequence", 3).put("artwork", "").toString(),
"https://node.test", true, setOf("https://node.test")) {}
assertNull(Shadows.shadowOf(lifecycle.get()).lastForegroundNotification.getLargeIcon())
assertNull(RecordingAudioMediaSession.metadata!!.description.iconBitmap)
} finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
}
@Test fun diagnosticReportExcludesPrivateMessagesAndRecordsRejectionStage() {
val app = RuntimeEnvironment.getApplication()
val web = android.webkit.WebView(app)
web.loadUrl("https://private-node.test/cloud?token=private-token")
try {
CompanionAudioBridge.receive(web, "private-invalid-payload", "https://private-node.test", true,
setOf("https://private-node.test")) {}
CompanionAudioBridge.receive(web, state().put("title", "PRIVATE SONG").put("duration", -1).toString(),
"https://private-node.test", true, setOf("https://private-node.test")) {}
val report = CompanionAudioDiagnostics.report(app)
assertTrue(report.contains("INVALID_JSON:"))
assertTrue(report.contains("INVALID_STATE:"))
for (privateValue in listOf("private-node", "private-token", "private-invalid-payload", "PRIVATE SONG", "12345678")) {
assertFalse(report.contains(privateValue))
}
} finally { web.destroy() }
}
@Test fun diagnosticHistoryIsBoundedWithoutDroppingStageCounts() {
repeat(100) {
CompanionAudioDiagnostics.record(CompanionAudioDiagnostics.Event.SERVICE_CREATED)
CompanionAudioDiagnostics.record(CompanionAudioDiagnostics.Event.SERVICE_DESTROYED)
}
val report = CompanionAudioDiagnostics.events()
val history = report.substringAfter("Recent transitions (seconds since boot):\n")
assertEquals(12, history.lines().count { it.isNotBlank() })
assertTrue(report.contains("SERVICE_CREATED:"))
}
private fun state() = JSONObject("""{"version":1,"action":"state","session":"12345678-1234-1234-1234-123456789abc","sequence":1,"title":"Current song","playing":true,"position":10,"duration":120,"previous":true,"next":true,"shuffle":true,"shuffled":false}""")
@Test fun malformedOrUnboundedMetadataCannotBecomeNativePlayback() {
for ((key, value) in listOf("version" to 2, "session" to "foreign", "sequence" to -1,
"position" to -1, "position" to 121, "duration" to 604801, "title" to "x".repeat(513),
"artwork" to "https://node.test/protected?token=secret")) {
assertTrue("Must reject $key", runCatching { CompanionAudioState.parse(state().put(key, value)) }.isFailure)
}
}
@Test fun currentMetadataPreservesPauseSeekAndQueueCapabilities() {
val parsed = CompanionAudioState.parse(state().put("playing", false).put("shuffled", true))
assertFalse(parsed.playing); assertTrue(parsed.shuffled)
assertTrue(parsed.previous && parsed.next && parsed.shuffle)
assertEquals(10.0, parsed.position, 0.0)
assertEquals(120.0, parsed.duration, 0.0)
assertEquals("", parsed.artwork)
}
@Test fun artworkNeverFetchesProtectedUrlsAndRejectsInvalidBytes() {
assertNull(decodeArtwork("https://node.test/protected"))
assertNull(decodeArtwork("data:image/jpeg;base64,AAAA"))
assertNull(decodeArtwork("data:image/jpeg;base64," + "A".repeat(90000)))
}
@Test fun serviceRestartWithoutLiveAuthorizedSessionDoesNotResumePlayback() {
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
try {
assertEquals(android.app.Service.START_NOT_STICKY, lifecycle.get().onStartCommand(null, 0, 1))
assertNull(CompanionAudioBridge.state)
assertNull(CompanionAudioService.instance)
} finally { lifecycle.destroy() }
}
}
@org.robolectric.annotation.Implements(android.media.session.MediaSession::class)
class RecordingAudioMediaSession : org.robolectric.shadows.ShadowMediaSession() {
companion object { var metadata: android.media.MediaMetadata? = null }
@org.robolectric.annotation.Implementation
fun setMetadata(value: android.media.MediaMetadata) { metadata = value }
}
@@ -0,0 +1,88 @@
package com.archipelago.app.ui.screens
import okhttp3.OkHttpClient
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.Assert.*
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.concurrent.CancellationException
class WebViewDownloadsTest {
private fun spec(url: String) = WebDownload(url, "test-agent", "session=test-only", "file.bin", "application/octet-stream")
@Test fun authenticatedDownloadWritesExactBytesAndReportsCompletion() {
MockWebServer().use { server ->
val bytes = ByteArray(256 * 1024 + 13) { (it % 251).toByte() }
server.enqueue(MockResponse().setBody(Buffer().write(bytes)))
val out = ByteArrayOutputStream()
var progress = 0L to 0L
assertEquals(bytes.size.toLong(), streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient(), onProgress = { done, total -> progress = done to total }))
assertArrayEquals(bytes, out.toByteArray())
assertEquals(bytes.size.toLong() to bytes.size.toLong(), progress)
assertEquals("session=test-only", server.takeRequest().getHeader("Cookie"))
}
}
@Test fun sameOriginRedirectKeepsSessionButCrossOriginNeverReceivesIt() {
MockWebServer().use { first -> MockWebServer().use { second ->
second.enqueue(MockResponse().setBody("final"))
first.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/relative"))
first.enqueue(MockResponse().setResponseCode(307).addHeader("Location", second.url("/target")))
val out = ByteArrayOutputStream()
streamWebDownload(spec(first.url("/start").toString()), out, OkHttpClient())
assertEquals("final", out.toString())
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
assertNull(second.takeRequest().getHeader("Cookie"))
} }
}
@Test fun authenticationFailureDoesNotSaveErrorBody() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setResponseCode(401).setBody("login required"))
val out = ByteArrayOutputStream()
val error = assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient()) }
assertTrue(error.message!!.startsWith("Sign in"))
assertEquals(0, out.size())
}
}
@Test fun redirectsAreBoundedAndUnsafeSchemesAreRejected() {
MockWebServer().use { server ->
repeat(6) { server.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/loop")) }
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/loop").toString()), ByteArrayOutputStream(), OkHttpClient()) }
assertEquals(6, server.requestCount)
}
for (url in listOf("file:///etc/passwd", "data:text/plain,test", "blob:test")) {
assertThrows(IOException::class.java) { streamWebDownload(spec(url), ByteArrayOutputStream(), OkHttpClient()) }
}
}
@Test fun cancellationAndDestinationFailureAreNotReportedAsComplete() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody("bytes"))
assertThrows(CancellationException::class.java) { streamWebDownload(spec(server.url("/").toString()), ByteArrayOutputStream(), OkHttpClient(), checkCancelled = { throw CancellationException() }) }
assertEquals(0, server.requestCount)
var progressCalled = false
val out = object : java.io.OutputStream() { override fun write(b: Int) { throw IOException("disk full") } }
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient(), onProgress = { _, _ -> progressCalled = true }) }
assertFalse(progressCalled)
}
}
@Test fun tlsDowngradeAndLoginHtmlAreRejected() {
var requests = 0
val client = OkHttpClient.Builder().addInterceptor { chain ->
requests++
okhttp3.Response.Builder().request(chain.request()).protocol(okhttp3.Protocol.HTTP_1_1)
.code(302).message("redirect").header("Location", "http://example.test/file").body("".toResponseBody(null)).build()
}.build()
assertThrows(IOException::class.java) { streamWebDownload(spec("https://example.test/file"), ByteArrayOutputStream(), client) }
assertEquals(1, requests)
MockWebServer().use { server ->
server.enqueue(MockResponse().addHeader("Content-Type", "Text/HTML; charset=utf-8").setBody("<html>Sign in</html>"))
val out = ByteArrayOutputStream()
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient()) }
assertEquals(0, out.size())
}
}
}
@@ -0,0 +1,90 @@
package com.archipelago.app.ui.screens
import android.view.View
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [28, 35])
class WebViewFullscreenTest {
@Test fun closingVideoInPipKeepsOpaqueCoverUntilActivityReturns() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val video = View(activity)
var hidden = 0
fullscreen.show(video) { hidden++ }
assertTrue(activity.enterPictureInPictureMode(android.app.PictureInPictureParams.Builder().build()))
assertTrue(activity.isInPictureInPictureMode)
fullscreen.hide()
assertNull(video.parent)
assertTrue(fullscreen.isActive)
assertEquals(1, hidden)
fullscreen.hide()
assertEquals(1, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun backExitsFullscreenWithoutFinishingActivityAndNotifiesOnce() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val video = View(activity)
var hidden = 0
fullscreen.show(video) { hidden++ }
assertNotNull(video.parent)
activity.onBackPressedDispatcher.onBackPressed()
assertNull(video.parent)
assertFalse(activity.isFinishing)
assertEquals(1, hidden)
fullscreen.hide()
assertEquals(1, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun duplicateRequestPreservesActiveViewAndCanReenterAfterExit() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val first = View(activity)
val second = View(activity)
var firstHidden = 0
var secondHidden = 0
fullscreen.show(first) { firstHidden++ }
fullscreen.show(second) { secondHidden++ }
assertNotNull(first.parent)
assertNull(second.parent)
assertEquals(0, firstHidden)
assertEquals(1, secondHidden)
fullscreen.hide()
fullscreen.show(second) { secondHidden++ }
assertNotNull(second.parent)
fullscreen.hide()
assertEquals(1, firstHidden)
assertEquals(2, secondHidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun rejectsOwnedViewWithoutReparentingAndHandlesUnavailableActivity() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val video = View(activity)
val owner = FrameLayout(activity).apply { addView(video) }
var hidden = 0
WebViewFullscreen(activity).show(video) { hidden++ }
assertSame(owner, video.parent)
WebViewFullscreen(null).show(null) { hidden++ }
assertEquals(2, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
}
+171 -1
View File
@@ -1,5 +1,175 @@
# Changelog # Changelog
## v1.9.0-alpha (2026-10-05)
Unpublished release candidate; qualification is still in progress.
- Keep Cuprate and NetBird supporting components out of app listings and consolidate BTCPay Server under Commerce.
- Default on-chain sends, channel opens and cooperative closes to a dynamic next-block fee target, preserving explicit slower and custom choices.
- Add reviewed fee-bump quotes, explicit budgets and durable operation tracking for supported wallet transactions.
- Preserve Nginx Proxy Manager storage, same-node upstream connectivity, certificates and access controls through managed migrations.
- Restrict public management access while retaining configured public apps and ACME certificate validation.
- Serve the Mempool explorer on the Angor indexer origin alongside its API.
- Include the self-contained LoRa flashing tool and explicit board selection in update and installer payloads.
- Preserve paid-file Lightning entitlements across restarts and recover settled invoices from LND. Retry delivery without paying again and retain purchased files in the owned cache.
- Return explicit payment-status errors with safe retry guidance when verification is unavailable.
- Keep upload progress on its original screen, show completion there or notify on other screens, and cancel active and queued uploads.
- Resume interrupted uploads while the app remains open, preserve the original destination, and verify saved file contents before reporting completion.
- Provision a unique private File Browser login on each node while keeping Cloud sign-in automatic and preserving existing accounts and files.
- Update Nostr dependencies to reject forged relay events and oversized encrypted messages; preserve native signing and encryption compatibility.
- Allow apps to opt in to a validated public-key list of user identities without granting signing access.
- Make transaction filters transparent and horizontally scrollable on mobile.
- Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps.
- Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.
- Offer Standard, Medium, Fast and custom fees when cooperatively closing Lightning channels.
- Add a clear-search icon to My Apps, Services and the App Store on desktop and mobile.
- Keep Angor Indexer and the optional Angor Relay in the signed app catalog. Full indexing requires a synced, unpruned Bitcoin node and its indexing dependencies.
## v1.8.22-alpha (2026-09-30)
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
- Network diagnostic failures no longer stop all apps or rebuild shared container networking.
- Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.
- Fixed companion dashboard builds still referencing a retired image registry.
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
- Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.
- Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.
- Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.
- Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.
- Named the app in compact readiness messages and kept app-card actions aligned at the bottom.
- Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.
- Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.
- Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.
- Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.
- Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.
- Preserved Gitea configuration and SSH operation during fresh setup and upgrades.
- Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.
- Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.
- Prevented manifest command arguments containing apostrophes from being corrupted in generated services.
## v1.8.21-alpha (2026-09-30)
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
## v1.8.20-alpha (2026-09-29)
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
- Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.
- Improved saving paid files into Files and reopening purchases without paying again.
- Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.
- Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.
- LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.
- Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.
- Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.
## v1.8.19-alpha (2026-09-28)
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
- Embedded AIUI now stays transparent so the dashboard background appears once.
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
## v1.8.18-alpha (2026-09-18)
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
## v1.8.17-alpha (2026-09-15)
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
- Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.
- Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.
- Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.
## v1.8.16-alpha (2026-09-15)
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
## v1.8.15-alpha (2026-09-13)
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
- Added regression coverage for Cuprate install and installed-state grouping.
- Release validation was rerun on the corrected tree before OTA and ISO publication.
## v1.8.14-alpha (2026-09-13)
- **Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.
- **Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.
- **GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.
- **Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.
## v1.8.13-alpha (2026-09-12)
- **GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.
- **Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.
- **Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services.
## v1.8.12-alpha (2026-09-11)
- **Fresh IndeedHub installs no longer share a fleet-wide encryption root.** The API now generates a persistent per-node AES master secret and shares it with the media worker through the platform's protected secret environment. Existing nodes migrate the exact legacy value they are already using before any container can be recreated, preserving access to encrypted data; an unreadable or empty existing root fails safely instead of being silently replaced. The manifest path, retired fallback installer, and container repair script follow the same rule.
- **The Companion download advertises and re-announces the APK it actually serves.** The Discover banner and its install prompt now share the no-cache APK metadata, visibly report Companion 0.5.32 build 52, and remember dismissal per Android build rather than forever, so an existing browser gets one useful update prompt when the APK changes. The ISO gate reads the expected version from the Android build itself instead of accepting the stale 0.5.28 payload.
- **GitWorkshop's dependency audit is clean.** The pinned upstream client keeps its separately reviewable Archipelago integration patch and now applies a deterministic dependency patch: safe lock refreshes plus targeted `fflate`, React Router, and Vitest upgrades remove all ten production advisories and all eight development advisories. A clean install reports zero vulnerabilities; type-check, all 152 upstream unit tests, and the exact Archipelago subpath build pass.
- **Every completed payment now gets the full Lightning-style receipt screen.** Cashu and Fedimint sends no longer leave the payment form open behind a token; wallet, QR-scan, Web5, and app-requested sends all replace their forms with the animated success state. Payment hashes, transaction IDs, ecash tokens/notes, mint details, and other useful references remain copyable in the receipt, and receive completions open the same distinct payment-success modal. Minibits claims retain a short-lived durable receipt so the visible modal still reports success when another dashboard or Companion context wins the claim-poll race, while concurrent watchers now share one bounded relay fetch instead of queueing several long polls.
- **TollGate provisioning closes the free-access path without taking over an admin network.** Confirmed upstream `TollGate-*` access points are moved from LAN onto the paid network, mint URLs are normalized consistently, and operators can set a validated Lightning payout address without replacing merchant keys or other revenue-share identities. Malformed existing identity data now stops provisioning safely instead of being overwritten.
- **Cashu receive gains a human-readable Minibits Lightning address.** The node derives the profile from the existing ecash recovery phrase, collects payments from the Minibits Nostr delivery relays, and redeems them into the Cashu wallet. Claim polling is single-flight, state and already-consumed tokens are written atomically with private permissions, same-second events are deduplicated without being skipped, restored seeds cannot reuse another wallet's profile, and pending claims retain the service key that encrypted them across key rotations. The UI identifies Minibits as a third-party beta service and recommends small balances.
- **Nostr sign-in returns directly to the app instead of a black or grey frame.** The top-level signer broker now stays loaded as a 1px non-interactive surface parked physically off-screen; removing or display-hiding its full-screen cross-origin iframe could leave stale compositor pixels above IndeeHub or GitWorkshop in Android WebView and mobile Chromium until refresh. One retained broker also keeps identity selection and its immediately following signing request in a continuous UI, while Companion no longer adds a separate 180ms cover that made GitWorkshop visibly flicker.
- **Gitea is sized for source and release hosting, not an empty demo.** Its manifest storage allowance is now 50GiB, release attachments accept individual files up to 10GiB, container-package owner storage remains unlimited, and HTTP/HTTPS proxy uploads share a streamed 10GiB ceiling. Existing repository, package, LFS and release data is unchanged.
- **Companion browser-tab signing now accepts the app gate's complete session.** A fresh external browser no longer needs a prior dashboard login/localStorage marker before the dashboard-origin signer can load. The app gate now issues both the shared HttpOnly node session and its matching readable CSRF token, so identity discovery and signing RPCs work after that one login instead of rendering a misleading “No identities found” state. Normal dashboard logout/session checks keep their existing behavior.
- **Fast Nostr identity choices now survive app startup and Companion tabs.** The tab/WebView broker waits for the application load event before opening its first-run picker, queues every NIP-07 call until the signer is initialized, and hands the just-selected public key directly to the immediate login request. GitWorkshop now turns that first-run choice into its normal extension account automatically, eliminating the startup race that surfaced as IndeedHub's “Could not get public key from extension.”
- **GitWorkshop makes network projects and Archipelago login explicit.** Its signed-in dashboard now includes recent repositories from the Nostr git index, the NIP-07 action reads “Extension / Archipelago,” and explicit Archipelago logins reopen the node identity chooser instead of silently reusing the first identity. Direct, user-triggered NIP-07 logins receive the same account-switch behavior for upstream apps such as IndeedHub.
- **IndeedHub tab signing now tracks the dashboard signer.** The injected provider supports the contained signer broker in direct tabs, is cache-busted, and is reconciled after dashboard-only updates as well as app installs and starts.
- **App launches now honor credentials everywhere.** Home, Spotlight, Discover, My Apps, and app-detail launches all pass through one platform-owned credential handoff, so Portainer's first-run token and the File Browser/PhotoPrism login details can no longer be skipped by launching from the Home grid.
- **Manage Updates returns to Download immediately after cancellation.** Canceling a stalled OTA now clears both the local staged state and progress state instead of leaving an incorrect Install button visible until the page is refreshed.
- **GitWorkshop no longer probes a desktop-only localhost relay or unauthenticated manifest.** The packaged upstream client disables its default `localhost:4869` nostrdb probe, uses credentialed manifest loading, drops dead lookup relays, and permits the dashboard's contained signer broker in its frame policy.
- **Rootless app ports self-heal when `pasta` drops a listener.** The five-minute container doctor compares every running container's declared Podman port bindings with actual host listeners and restarts only a container whose listener vanished. TCP and UDP are checked separately, avoiding false restarts of services such as NetBird's UDP port 3478. This covers the intermittent Nginx Proxy Manager port 8081 rebind failure without requiring a node reboot.
- **Nostr identity actions now use one contained, companion-safe signing experience.** The old full-screen signer has been replaced by the same in-app consent surface used by embedded apps, with the animated identity circle as a brief signing indicator and an explicit completion state. Editing an identity now ends on a dedicated success screen that reports relay coverage and the event ID instead of disappearing back into the form. The app developer guide defines this platform-owned NIP-07 flow and its browser/Companion test matrix so apps do not add a second signer UI.
- **Discovery merchandising is now owned by the signed app registry.** The catalog declares the Popular Apps set and contribution promotion; Discover renders two desktop rows of popular apps, then the “Your node. Your source.” banner, then the remaining apps. GitWorkshop uses a cache-busted copy of its current upstream mark, and its catalog entry identifies the canonical Archipelago maintainer npub.
- **Companion opens Source in its native WebView and installs the node certificate.** GitWorkshop is a top-level page in the Companion in-app browser—not a dashboard iframe—and its injected provider uses the contained, consent-gated signer broker. The generic native launcher turns relative app paths into complete URLs before handing them to Android. The Node certificate button uses Android's system credential installer in the companion instead of an unsupported WebView download.
- **Node certificate guidance now covers installation and the failures people actually see.** Settings includes the complete macOS, iOS/iPadOS, Windows, Android, Linux, Firefox, and Arch/Manjaro steps; reminds users to restart browsers that cache trust decisions; separates certificate trust from DNS; and maps common browser symptoms to their likely cause.
- **Tab and Companion Nostr sign-in no longer loses the broker or an early identity choice.** The signer route validates the shared app-gate session with the implemented, authenticated `system.get-hostname` RPC instead of the nonexistent `system.get-version`. The provider also exposes a sticky identity subscription so a GitWorkshop React listener that mounts just after selection still completes the normal NIP-07 login. The dashboard service worker no longer precaches the signer route or provider, preventing an old bridge from surviving an update. This repairs GitWorkshop automatic login and IndeeHub's external mobile-browser flow.
- **The App Store now makes Archipelago's source an invitation to contribute.** GitWorkshop has its real upstream icon and source-focused description, plus a dedicated “Your node. Your source.” banner explaining that users can browse the code, clone with ngit, and send issues, patches, and reviews over Nostr.
- **Source now packages GitWorkshop instead of maintaining a separate Nostr Git interface.** The pinned upstream client runs read-only behind the authenticated app gate, launches at the dashboard's same origin under `/app/archipelago-source/`, and uses the node's consent-gated NIP-07 bridge. The upstream revision declares no license; Archipelago's owner accepted that redistribution risk without representing the client as licensed. Production publication still requires a tested canonical Archipelago NIP-34/GRASP announcement.
- **Changing the node password now reports a wrong current password directly.** The backend was already rejecting the request before changing either the web or SSH password, but its error sanitizer replaced that safe, actionable explanation with “check server logs.” The real validation error now reaches the password dialog.
- **The periodic container doctor runs from the same canonical path used by OTA updates.** Its systemd unit and embedded bootstrap still pointed at the retired source-checkout path while release updates installed the script under `/opt/archipelago/scripts`, leaving the doctor failed on nodes without that checkout. ISO, OTA bootstrap, and the deployment smoke test now agree on the `/opt` path.
## v1.8.11-alpha (2026-09-07) ## v1.8.11-alpha (2026-09-07)
- **Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path. - **Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.
@@ -52,7 +222,7 @@
- **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged. - **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.
- **Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed. - **Every app in the store is now a first-class platform app.** The remaining platform apps carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. Retired apps are dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
- **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it. - **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it.
+38 -1
View File
@@ -64,12 +64,49 @@ App submissions must:
## Pull requests ## Pull requests
1. Open one focused PR per behavior or documentation change. Contributions, PRs and reviews live on **ngit**. Clone the canonical repository:
```text
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
```
Gitea is a conventional Git mirror of accepted `main` commits and release tags.
You do not need to open a duplicate Gitea PR. Existing Gitea contributions will
be reviewed and linked to their ngit replacement or accepted result before
closure.
1. Open one focused ngit PR per behavior or documentation change.
2. Explain what changed, why it changed, and how it was verified. 2. Explain what changed, why it changed, and how it was verified.
3. Include screenshots for UI changes. 3. Include screenshots for UI changes.
4. Link relevant issues or docs. 4. Link relevant issues or docs.
5. Keep generated catalog changes in sync with manifest changes. 5. Keep generated catalog changes in sync with manifest changes.
### Maintainer publication gate
Merge once through the ngit contribution workflow, then push the exact same
accepted commits to Gitea. Do not independently merge or squash on each mirror.
Publish identical release tag objects, including annotations and signatures.
After pushing main, verify:
```bash
python3 scripts/check-git-mirrors.py --local
```
Before publishing release artifacts, also check the actual release tag:
```bash
python3 scripts/check-git-mirrors.py --local --ref refs/tags/v1.9.0-alpha
```
Use the release's actual tag name. Missing refs, inaccessible mirrors or differing
object IDs block publication. Record the ngit PR disposition and resulting merge
commit in the release acceptance ledger. Resolve drift deliberately; do not
force-push or delete published history without explicit approval.
The checker is read-only. `--all` audits every advertised branch and tag; ngit
proposal branches may intentionally differ from Gitea. A main-only pass proves
only main parity, and no Git ref check verifies PR discussions or review state.
Suggested commit format: Suggested commit format:
```text ```text
+8 -1
View File
@@ -57,6 +57,13 @@ ElevenLabs TTS under a commercial-use plan.
## Redistributed software (ISO and container registry) ## Redistributed software (ISO and container registry)
- **GitWorkshop** — https://github.com/DanConwayDev/gitworkshop — pinned at
`dc36db64f6a2cca29d109829eabaf0a49d4bf4da`. The upstream revision declares
no software license. Archipelago applies a documented integration patch and
redistributes the resulting static application under an explicit owner risk
acceptance dated 2026-09-11; this notice does not claim or grant upstream
copyright permission. See `docker/archipelago-source/UPSTREAM.md`.
The Archipelago OS image is based on Debian and redistributes Debian packages The Archipelago OS image is based on Debian and redistributes Debian packages
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs (including the Linux kernel, GRUB, and non-free firmware/microcode blobs
required for hardware support); per-package license texts are preserved at required for hardware support); per-package license texts are preserved at
@@ -65,7 +72,7 @@ is available via Debian (https://snapshot.debian.org) as referenced in each
release's notes. Container images offered through the app catalog and mirror release's notes. Container images offered through the app catalog and mirror
registry remain under their upstream licenses (including GPL/AGPL software registry remain under their upstream licenses (including GPL/AGPL software
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich, such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in Jellyfin, MariaDB, and strfry); source links are provided in
the app catalog. The modified mempool-frontend image is built from the app catalog. The modified mempool-frontend image is built from
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source). `docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
+17 -1
View File
@@ -1,5 +1,7 @@
# Archipelago # Archipelago
> **Alpha testing:** Archipelago is experimental software. Any funds you put on it are at your own risk.
> Self-sovereign Bitcoin node OS and manifest-driven app platform. > Self-sovereign Bitcoin node OS and manifest-driven app platform.
Archipelago is a bootable personal server OS for Bitcoin infrastructure, Archipelago is a bootable personal server OS for Bitcoin infrastructure,
@@ -11,7 +13,21 @@ Podman containers managed by the Rust backend.
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/) [![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/)
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/) [![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]() [![Version](https://img.shields.io/badge/version-1.8.13--alpha-blue)](https://source.archipelago-foundation.org/lfg2025/archy/releases)
## Current release
The current pre-release is **v1.8.13-alpha**. Release notes and signed OTA
artifacts are published on [Gitea](https://source.archipelago-foundation.org/lfg2025/archy/releases).
The same source is mirrored through ngit for Nostr-native cloning and
contribution:
```
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
```
Clone with ngit, or use the Gitea mirror when you need a conventional Git
remote. Contributions should follow [CONTRIBUTING.md](CONTRIBUTING.md).
## What is here ## What is here
+3 -2
View File
@@ -46,13 +46,14 @@ interface RateBucket {
const rateBuckets = new Map<string, RateBucket>() const rateBuckets = new Map<string, RateBucket>()
// Clean up stale buckets every 5 minutes // Vite imports this module during builds too; cleanup must not keep the
// process alive once compilation has finished.
setInterval(() => { setInterval(() => {
const now = Date.now() const now = Date.now()
for (const [key, bucket] of rateBuckets) { for (const [key, bucket] of rateBuckets) {
if (now > bucket.resetAt) rateBuckets.delete(key) if (now > bucket.resetAt) rateBuckets.delete(key)
} }
}, 5 * 60_000) }, 5 * 60_000).unref()
function getClientIp(req: IncomingMessage): string { function getClientIp(req: IncomingMessage): string {
return req.socket.remoteAddress ?? 'unknown' return req.socket.remoteAddress ?? 'unknown'
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { archyBridge } from '@/services/archyBridge'
const originalParent = window.parent
const origin = 'https://node.example'
afterEach(() => { archyBridge.destroy(); Object.defineProperty(window, 'parent', { value: originalParent, configurable: true }); vi.restoreAllMocks() })
describe('trusted provider setup bridge', () => {
it('accepts configuration only from the embedding parent, rejects siblings and other origins', () => {
const parent = { postMessage: vi.fn() }
Object.defineProperty(window, 'parent', { value: parent, configurable: true })
archyBridge.init(origin)
const listener = vi.fn(); const unsubscribe = archyBridge.onProviderConfigured(listener)
const send = (source: unknown, from: string, provider = 'openai') => window.dispatchEvent(new MessageEvent('message', { source: source as Window, origin: from, data: { type: 'ai:provider-configured', provider, model: 'test-model' } }))
send({}, origin); send(parent, 'https://evil.example'); send(parent, origin, 'arbitrary')
expect(listener).not.toHaveBeenCalled()
send(parent, origin)
expect(listener).toHaveBeenCalledExactlyOnceWith({ provider: 'openai', model: 'test-model' })
archyBridge.requestAISetup()
expect(parent.postMessage).toHaveBeenLastCalledWith({ type: 'ai:setup-request' }, origin)
unsubscribe()
})
it('replays the selection when the composer mounts after the handshake', () => {
const parent = { postMessage: vi.fn() }; Object.defineProperty(window, 'parent', { value: parent, configurable: true })
archyBridge.init(origin)
window.dispatchEvent(new MessageEvent('message', { source: parent as unknown as Window, origin, data: { type: 'ai:provider-configured', provider: 'local' } }))
const listener = vi.fn(); const unsubscribe = archyBridge.onProviderConfigured(listener)
expect(listener).toHaveBeenCalledExactlyOnceWith({ provider: 'local', model: '' }); unsubscribe()
})
})
@@ -249,6 +249,24 @@ describe('useAI', () => {
expect(chatStore.isStreaming).toBe(false) expect(chatStore.isStreaming).toBe(false)
}) })
it.each([502, 503, 429, 401])('distinguishes HTTP %s from a missing credential', async (status) => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status, text: async () => 'Provider request failed' })
const store = useChatStore(); store.webSearchEnabled = false
const ai = useAI(); ai.needsApiKey.value = false
await ai.sendMessage('test')
expect(ai.needsApiKey.value).toBe(status === 401)
expect(store.isStreaming).toBe(false)
})
it('offers funding for a Routstr payment-required response without mislabeling it a key error', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 402, text: async () => JSON.stringify({ error: { message: 'Your spending allowance is exhausted' } }) })
const store = useChatStore(); store.webSearchEnabled = false
const ai = useAI(); ai.setProvider('routstr'); ai.needsApiKey.value = false; ai.needsFunding.value = false
await ai.sendMessage('test')
expect(ai.needsFunding.value).toBe(true); expect(ai.needsApiKey.value).toBe(false)
expect(store.messages.find(m => m.role === 'assistant')?.content).toContain('spending allowance')
})
it('handles connection errors gracefully', async () => { it('handles connection errors gracefully', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure')) globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure'))
@@ -123,6 +123,7 @@
:style="modelPickerDropdownStyle" :style="modelPickerDropdownStyle"
@click.stop @click.stop
> >
<button v-if="archyBridge.isInArchy()" class="w-full text-left rounded-lg px-3 py-2 text-sm text-white/90 hover:bg-white/10" @click="showModelPicker = false; archyBridge.requestAISetup()">AI connection</button>
<div v-for="provider in availableProviders" :key="provider.id"> <div v-for="provider in availableProviders" :key="provider.id">
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40"> <p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
{{ provider.name }} {{ provider.name }}
@@ -247,6 +248,7 @@ import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel' import { useContentPanel } from '@/composables/useContentPanel'
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export' import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
import { parseImportFile } from '@/utils/conversation-import' import { parseImportFile } from '@/utils/conversation-import'
import { archyBridge } from '@/services/archyBridge'
import { useComparisonMode } from '@/composables/useComparisonMode' import { useComparisonMode } from '@/composables/useComparisonMode'
defineProps<{ defineProps<{
@@ -332,8 +334,7 @@ const modelDisplayName = computed(() => {
}) })
function selectModel(providerId: string, modelId: string) { function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock') if (setProvider(providerId as Parameters<typeof setProvider>[0])) setModel(modelId)
setModel(modelId)
showModelPicker.value = false showModelPicker.value = false
} }
@@ -186,8 +186,9 @@ defineEmits<{
}>() }>()
const chatStore = useChatStore() const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = useAI() const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey, needsFunding } = useAI()
const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel() const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { archyBridge } from '@/services/archyBridge'
import { useCodeContext } from '@/composables/useCodeContext' import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport' import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext() const codeContext = useCodeContext()
@@ -206,11 +207,19 @@ const showSettings = ref(false)
// without fixing anything). // without fixing anything).
watch(needsApiKey, (needs) => { watch(needsApiKey, (needs) => {
if (needs) { if (needs) {
showSettings.value = true if (archyBridge.isInArchy()) archyBridge.requestAISetup()
else showSettings.value = true
needsApiKey.value = false needsApiKey.value = false
} }
}) })
watch(needsFunding, needed => {
if (!needed) return
if (archyBridge.isInArchy()) archyBridge.requestAISetup('funding')
else showSettings.value = true
needsFunding.value = false
})
// Scroll position memory per conversation // Scroll position memory per conversation
const scrollPositions = new Map<string, number>() const scrollPositions = new Map<string, number>()
@@ -1,5 +1,9 @@
<template> <template>
<div class="space-y-4"> <div v-if="embedded" class="space-y-3">
<p class="text-sm">AI connections and private keys are managed by this node.</p>
<button class="rounded-lg px-3 py-2 bg-white/10 text-sm" @click="archyBridge.requestAISetup()">Manage AI connection</button>
</div>
<div v-else class="space-y-4">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'"> <h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
API Keys API Keys
</h3> </h3>
@@ -93,10 +97,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { archyBridge } from '@/services/archyBridge'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault' import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
const embedded = archyBridge.isInArchy()
const { isDark } = useTheme() const { isDark } = useTheme()
interface ProviderInfo { interface ProviderInfo {
@@ -156,5 +162,5 @@ async function removeKey(provider: string) {
await loadProviders() await loadProviders()
} }
onMounted(loadProviders) onMounted(() => { if (!embedded) void loadProviders() })
</script> </script>
+31 -28
View File
@@ -13,7 +13,7 @@ import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch' import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock' type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock' | 'openai' | 'auto' | 'local'
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/) // API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
const BASE = import.meta.env.BASE_URL || '/' const BASE = import.meta.env.BASE_URL || '/'
@@ -120,34 +120,15 @@ Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Cast
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out. Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
${librarySection}` ${librarySection}`
const activeProvider = ref<Provider>('claude') const activeProvider = ref<Provider>(archyBridge.isInArchy() ? 'auto' : 'claude')
const activeModel = ref('claude-haiku-4.5') const activeModel = ref('claude-haiku-4.5')
// One-shot signal a send/regenerate/edit failure looked like a missing or // Credentials require setup; network failures and provider outages require retry.
// invalid API key (or an unreachable proxy) rather than a transient/server
// error — consumed by ChatWindow.vue to auto-open Settings so the user isn't
// left in a dead end with no obvious next step. Deliberately narrow (401/403,
// explicit "api key"/"unauthorized" text, or a connection-level failure to
// reach the proxy at all) so a rate-limited or momentarily-flaky provider
// response does NOT send the user to Settings for a problem Settings can't
// fix. Reset to false by the consumer immediately after acting on it, so it
// behaves as a pulse rather than sticky state (each new failure can re-fire).
const needsApiKey = ref(false) const needsApiKey = ref(false)
const needsFunding = ref(false)
function looksLikeMissingApiKey(err: string): boolean { function looksLikeMissingApiKey(err: string): boolean {
const lower = err.toLowerCase() return /\b(401|403)\b|api[ _-]?key|unauthorized|authentication_error|credential/i.test(err)
return (
/\b(401|403)\b/.test(err) ||
lower.includes('api key') ||
lower.includes('x-api-key') ||
lower.includes('unauthorized') ||
lower.includes('authentication_error') ||
lower.includes('failed to fetch') ||
lower.includes('econnrefused') ||
lower.includes(' 502') ||
lower.includes(' 503')
)
} }
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ─── // ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
@@ -161,7 +142,7 @@ async function refreshRoutstrModels() {
routstrModelsFetched = true routstrModelsFetched = true
try { try {
const res = await apiFetch(ROUTSTR_MODELS_PATH) const res = await apiFetch(ROUTSTR_MODELS_PATH)
if (!res.ok) return if (!res.ok) { routstrModelsFetched = false; return }
const data = await res.json() const data = await res.json()
if (Array.isArray(data?.data)) { if (Array.isArray(data?.data)) {
routstrModels.value = data.data routstrModels.value = data.data
@@ -170,13 +151,21 @@ async function refreshRoutstrModels() {
id: m.id as string, id: m.id as string,
name: (m.name as string) || (m.id as string), name: (m.name as string) || (m.id as string),
})) }))
} if (activeProvider.value === 'routstr' && activeModel.value === 'routstr-unavailable' && routstrModels.value[0]) activeModel.value = routstrModels.value[0].id
} else { routstrModelsFetched = false }
} catch { } catch {
routstrModelsFetched = false // allow a retry on the next send/open routstrModelsFetched = false // allow a retry on the next send/open
} }
} }
const availableProviders = computed(() => { const availableProviders = computed(() => {
if (archyBridge.isInArchy()) return [
{ id: 'local' as Provider, name: 'Local AI', models: [{ id: 'node', name: 'Node configuration' }] },
{ id: 'auto' as Provider, name: 'Node AI', models: [{ id: 'node', name: 'Node configuration' }] },
{ id: 'claude' as Provider, name: 'Claude API', models: [{ id: 'node', name: 'Node configuration' }] },
{ id: 'openai' as Provider, name: 'OpenAI API', models: [{ id: activeProvider.value === 'openai' ? activeModel.value : 'node', name: activeProvider.value === 'openai' ? activeModel.value : 'Configure model' }] },
{ id: 'routstr' as Provider, name: 'Routstr (sats)', models: routstrModels.value.length ? routstrModels.value : [{ id: 'routstr-unavailable', name: 'Models unavailable — retry' }] },
]
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [ const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{ {
id: 'routstr', id: 'routstr',
@@ -187,7 +176,7 @@ const availableProviders = computed(() => {
}, },
{ {
id: 'claude', id: 'claude',
name: 'Claude (Max)', name: 'Claude API',
models: [ models: [
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' }, { id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
@@ -207,24 +196,36 @@ const availableProviders = computed(() => {
}) })
providers.push({ providers.push({
id: 'mock', id: 'mock',
name: 'Local (no API)', name: 'Demo echo',
models: [{ id: 'echo', name: 'Echo (mirror input)' }], models: [{ id: 'echo', name: 'Echo (mirror input)' }],
}) })
return providers return providers
}) })
function setProvider(provider: Provider) { function setProvider(provider: Provider) {
if (archyBridge.isInArchy()) {
if (provider === 'routstr' && activeProvider.value === 'routstr') return true
archyBridge.requestAISetup(); return false
}
activeProvider.value = provider activeProvider.value = provider
const p = availableProviders.value.find((pp) => pp.id === provider) const p = availableProviders.value.find((pp) => pp.id === provider)
if (p && p.models.length > 0) { if (p && p.models.length > 0) {
activeModel.value = p.models[0].id activeModel.value = p.models[0].id
} }
return true
} }
function setModel(model: string) { function setModel(model: string) {
if (archyBridge.isInArchy() && activeProvider.value !== 'routstr') { archyBridge.requestAISetup(); return }
activeModel.value = model activeModel.value = model
} }
archyBridge.onProviderConfigured(({ provider, model }) => {
activeProvider.value = provider
activeModel.value = model || (provider === 'routstr' ? routstrModels.value[0]?.id || 'routstr-unavailable' : 'node')
if (provider === 'routstr') void refreshRoutstrModels()
})
interface ChatMessage { interface ChatMessage {
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
@@ -448,6 +449,7 @@ async function streamRoutstr(
}) })
const bodyText = await res.text().catch(() => '') const bodyText = await res.text().catch(() => '')
if (res.status === 402) needsFunding.value = true
if (!res.ok) { if (!res.ok) {
// The node's refusals carry a plain-language error.message (budget not // The node's refusals carry a plain-language error.message (budget not
// set, budget spent, wallet can't fund) — surface it verbatim. // set, budget spent, wallet can't fund) — surface it verbatim.
@@ -974,5 +976,6 @@ export function useAI() {
setProvider, setProvider,
setModel, setModel,
needsApiKey, needsApiKey,
needsFunding,
} }
} }
+1
View File
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
// Only embedded when explicitly requested via ?embedded param // Only embedded when explicitly requested via ?embedded param
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded') const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag ;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
+5 -5
View File
@@ -2,13 +2,13 @@
<div <div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300" class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
:class="[]" :class="[]"
:style="isDark :style="isEmbedded
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' } ? { background: 'transparent' }
: isEmbedded : isDark
? { background: 'transparent' } ? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: { backgroundColor: '#f5f4f1' }" : { backgroundColor: '#f5f4f1' }"
> >
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" /> <div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
<!-- Desktop layout --> <!-- Desktop layout -->
<div <div
+22 -1
View File
@@ -55,6 +55,10 @@ interface ThemeInfo {
type PermissionsCallback = (categories: AIContextCategory[]) => void type PermissionsCallback = (categories: AIContextCategory[]) => void
type ThemeCallback = (theme: ThemeInfo) => void type ThemeCallback = (theme: ThemeInfo) => void
export interface AIProviderSelection { provider: 'auto' | 'local' | 'claude' | 'openai' | 'routstr'; model: string }
const providerCallbacks = new Set<(selection: AIProviderSelection) => void>()
let currentProvider: AIProviderSelection | null = null
let requestId = 0 let requestId = 0
const pendingRequests = new Map<string, { const pendingRequests = new Map<string, {
resolve: (value: unknown) => void resolve: (value: unknown) => void
@@ -80,12 +84,19 @@ function postToParent(msg: unknown) {
function handleMessage(event: MessageEvent) { function handleMessage(event: MessageEvent) {
// Always validate origin — reject if not configured or mismatched // Always validate origin — reject if not configured or mismatched
if (!allowedOrigin || event.origin !== allowedOrigin) return if (!allowedOrigin || event.origin !== allowedOrigin || event.source !== window.parent) return
const msg = event.data const msg = event.data
if (!msg || typeof msg.type !== 'string') return if (!msg || typeof msg.type !== 'string') return
switch (msg.type) { switch (msg.type) {
case 'ai:provider-configured': {
if (!['auto', 'local', 'claude', 'openai', 'routstr'].includes(msg.provider)) break
const selection = { provider: msg.provider as AIProviderSelection['provider'], model: typeof msg.model === 'string' ? msg.model : '' }
currentProvider = selection
for (const callback of providerCallbacks) callback(selection)
break
}
case 'context:response': { case 'context:response': {
const pending = pendingRequests.get(msg.id) const pending = pendingRequests.get(msg.id)
if (pending) { if (pending) {
@@ -223,11 +234,21 @@ export const archyBridge = {
} }
}, },
requestAISetup(reason?: 'funding') { postToParent({ type: 'ai:setup-request', ...(reason ? { reason } : {}) }) },
onProviderConfigured(callback: (selection: AIProviderSelection) => void) {
providerCallbacks.add(callback)
if (currentProvider) callback(currentProvider)
return () => { providerCallbacks.delete(callback) }
},
/** Clean up listeners */ /** Clean up listeners */
destroy() { destroy() {
window.removeEventListener('message', handleMessage) window.removeEventListener('message', handleMessage)
pendingRequests.clear() pendingRequests.clear()
initialized = false initialized = false
currentProvider = null
allowedOrigin = null
}, },
/** Check if running inside Archy iframe */ /** Check if running inside Archy iframe */
+15 -6
View File
@@ -57,12 +57,8 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6]) /* Standalone canvas fallback. Embedded mode overrides this below so
EXCEPT the embedded Chat page, which intentionally goes transparent so Archy's wallpaper remains visible through the iframe. */
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
no background-color here, "transparent" fell through to the browser's
default white canvas instead. Match the theme's own dark/light default so
nothing above this ever needs to guess. */
background-color: #0a0a0a; background-color: #0a0a0a;
} }
@@ -70,6 +66,19 @@ html.light body {
background-color: #faf9f6; background-color: #faf9f6;
} }
/* The host owns the wallpaper when AIUI is embedded. The document canvas
must be transparent too, otherwise it hides the host behind ChatPage. */
html.aiui-embedded {
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
with a different scheme an opaque canvas despite transparent CSS. */
color-scheme: dark;
}
html.aiui-embedded,
html.aiui-embedded body {
background: transparent;
}
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */ /* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
@layer components { @layer components {
+34
View File
@@ -34,6 +34,40 @@ Add an entry to `catalog.json`:
For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional. For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional.
For new apps, include `containerConfig` so the backend knows how to create the container. For new apps, include `containerConfig` so the backend knows how to create the container.
## Storefront layout
Discovery merchandising is app-registry data, not node-OS layout. The optional
top-level `storefront` block defines the ordered Popular Apps rows and the
promotional banners placed before the remaining `All Apps` grid:
```json
{
"storefront": {
"popular": ["bitcoin-knots", "lnd", "btcpay-server"],
"promotions": [{
"id": "my-app",
"banner": "/assets/img/featured/my-app.webp",
"eyebrow": "open source",
"headline": "Build together.",
"description": "Catalog-controlled promotional copy.",
"tag": "NOSTR // SOURCE",
"path": "/npub1maintainer/project",
"launchLabel": "Open",
"installLabel": "Install",
"detailsLabel": "Learn more →"
}]
}
}
```
Only IDs present in `apps` render. An optional promotion `path` deep-links into
the installed app; Archipelago uses this to open the canonical signed Nostr
repository rather than GitWorkshop's generic dashboard. New dashboards prefer `storefront` from the
daemon-verified signed catalog and use the bundled community copy as a local
fallback. `scripts/generate-app-catalog.sh` carries this block into the signed
release artifact; changing it does not require a node OS release once that
artifact is published.
## Categories ## Categories
money, commerce, data, home, nostr, networking, community, development, l484 money, commerce, data, home, nostr, networking, community, development, l484
+71 -17
View File
@@ -9,19 +9,31 @@
"description": "Bitcoin documentaries with Nostr identity.", "description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE" "tag": "NOSTR IDENTITY // YOUR NODE"
}, },
"storefront": {
"popular": [
"bitcoin-knots",
"lnd",
"btcpay-server",
"mempool",
"filebrowser",
"homeassistant"
],
"promotions": [
{
"id": "archipelago-source",
"banner": "/assets/img/featured/archipelago-source-banner.webp",
"eyebrow": "open source",
"headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
}
]
},
"apps": [ "apps": [
{
"id": "adguardhome",
"title": "AdGuard Home",
"version": "v0.107.79",
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
"icon": "",
"author": "AdGuard",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79",
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
},
{ {
"id": "alby-hub", "id": "alby-hub",
"title": "Alby Hub", "title": "Alby Hub",
@@ -247,6 +259,19 @@
}, },
"tier": "optional" "tier": "optional"
}, },
{
"id": "archipelago-source",
"title": "GitWorkshop",
"version": "0.4.0",
"description": "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.",
"icon": "/assets/img/app-icons/gitworkshop-dc36db6.svg",
"author": "GitWorkshop contributors",
"maintainerNpub": "npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg",
"category": "development",
"tier": "optional",
"repoUrl": "https://github.com/DanConwayDev/gitworkshop",
"dockerImage": "localhost/archipelago-source:local"
},
{ {
"id": "grafana", "id": "grafana",
"title": "Grafana", "title": "Grafana",
@@ -353,13 +378,13 @@
{ {
"id": "mempool", "id": "mempool",
"title": "Mempool Explorer", "title": "Mempool Explorer",
"version": "3.0.0", "version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.", "description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp", "icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool", "author": "Mempool",
"category": "money", "category": "money",
"tier": "core", "tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1", "dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool", "repoUrl": "https://github.com/mempool/mempool",
"requires": [ "requires": [
"bitcoin-knots", "bitcoin-knots",
@@ -411,13 +436,13 @@
{ {
"id": "nginx-proxy-manager", "id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager", "title": "Nginx Proxy Manager",
"version": "2.12.1", "version": "2.14.0",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).", "description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. The node's public web server forwards configured domains through this service, preserving its access lists, certificates and custom routes.",
"icon": "/assets/img/app-icons/nginx.svg", "icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager", "author": "Nginx Proxy Manager",
"category": "networking", "category": "networking",
"tier": "optional", "tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest", "dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08",
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager" "repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
}, },
{ {
@@ -619,6 +644,35 @@
"/var/lib/archipelago/vaultwarden:/data" "/var/lib/archipelago/vaultwarden:/data"
] ]
} }
},
{
"id": "angor-indexer",
"title": "Angor Indexer",
"version": "1.0.2",
"description": "Bitcoin indexer endpoint for Angor with the existing Mempool explorer. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
"author": "Angor / Archipelago",
"requires": [
"Mempool API",
"Unpruned Bitcoin"
],
"category": "money",
"tier": "optional",
"icon": "/assets/img/app-icons/angor-green.png",
"repoUrl": "https://github.com/block-core/angor"
},
{
"id": "angor-relay",
"title": "Angor Relay",
"version": "1.1.2",
"description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.",
"dockerImage": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
"author": "Angor / Archipelago",
"requires": [],
"category": "nostr",
"tier": "optional",
"icon": "/assets/img/app-icons/angor-green.png",
"repoUrl": "https://github.com/hoytech/strfry"
} }
] ]
} }
+2
View File
@@ -91,3 +91,5 @@ Adding a new app requires updates in multiple places:
## Port Assignments ## Port Assignments
See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000. See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000.
Before submitting an app, complete **Launch acceptance: credentials, signer, and HTTP nodes** in `docs/app-developer-guide.md`. A generated password needs an authenticated credential interstitial; native Nostr login needs a tested first-launch chooser. Container health alone is not launch acceptance.
+2
View File
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
| did-wallet | 8083 | TCP | Web UI | 18083 | | did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 | | router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 | | meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
| archipelago-source | 8337 | TCP | Authenticated source UI | 18337 |
## Development Ports (Offset: +10000) ## Development Ports (Offset: +10000)
@@ -53,6 +54,7 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| DID Wallet | http://localhost:18083 | | DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 | | Router | http://localhost:18084 |
| Meshtastic | http://localhost:14403 | | Meshtastic | http://localhost:14403 |
| GitWorkshop | http://localhost:18337 |
## Port Conflict Resolution ## Port Conflict Resolution
-91
View File
@@ -1,91 +0,0 @@
app:
id: adguardhome
name: AdGuard Home
version: v0.107.79
upstream:
kind: github
repo: AdguardTeam/AdGuardHome
description: >-
Network-wide ad and tracker blocking: a DNS server that filters every
device on your LAN, with a web console for rules and client management.
container:
image: source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 3030
container: 3000
protocol: tcp
bind: 127.0.0.1
# 3030, not AdGuard Home's conventional 3000: Grafana owns :3000 on a
# node, and both being installable means the host ports must not
# collide (the orchestrator refuses/loads warn on overlap).
# open: the setup wizard and admin console carry AdGuard Home's own
# login; the gate fronts the port (TLS, header fixes) without a
# second cookie challenge.
auth: open
auth_rationale: >-
AdGuard Home enforces its own admin login on the console, and the
first-run wizard must answer before any account exists.
- host: 53
container: 53
protocol: udp
# none: plain DNS must answer every unauthenticated query from LAN
# devices — a login page in front of :53 breaks every client on the
# network by design.
auth: none
auth_rationale: >-
Plain DNS answers unauthenticated by protocol: resolvers and clients
send queries directly; a login challenge would make DNS unreachable.
- host: 53
container: 53
protocol: tcp
auth: none
auth_rationale: >-
DNS-over-TCP fallback (truncated responses, zone transfers); same
protocol-level requirement as the UDP port.
volumes:
- type: bind
source: /var/lib/archipelago/adguardhome
target: /opt/adguardhome
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:3030
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Admin console
description: AdGuard Home web console
type: ui
port: 3030
protocol: http
path: /
metadata:
author: AdGuard
category: networking
repo: https://github.com/AdguardTeam/AdGuardHome
tier: optional
+109
View File
@@ -0,0 +1,109 @@
# Angor Indexer
Mainnet indexer endpoint for Angor, serving the existing Mempool explorer at
the same origin. The service reuses this node's Mempool frontend/backend and
Electrum index instead of creating another explorer or blockchain database.
An unpruned, fully synced Bitcoin node is required. Installing against a pruned
node must show the existing archival-node requirement; it must never silently
unprune or replace its Bitcoin data.
## Connect Angor
Install **Angor Indexer** in the store. Its API appears under **Services**.
In Angor settings, use `http://<node-address>:8998/` as the custom indexer origin.
The `/health` endpoint reports readiness against Mempool's indexed block height;
it returns 503 while that backend is unavailable. Index building may take time.
Browser clients require a reachable HTTPS origin with a trusted certificate.
Configure your HTTPS reverse proxy to forward to port 8998, then use that HTTPS
origin in Angor. Do not disable browser TLS checks. The API supports both
`/api/v1/` and `/api/` paths, transaction broadcast, and CORS without cookies.
This endpoint intentionally exposes public blockchain queries and transaction
broadcast through the app gate without dashboard-cookie login. It has no Bitcoin
RPC password, wallet keys, or persistent wallet data. The backend stays on the
managed container network; its private port does not become publicly exposed.
You can change network access using the node's normal access controls.
## Relay
A relay is optional. Angor can continue using its configured external relays.
Install **Angor Relay** separately to host project metadata locally, then add
`ws://<node-address>:8091/` in Angor, or a trusted `wss://` proxy origin for browser
clients. Its storage and configuration are separate from the node's internal
relay; installing or uninstalling it does not change the internal relay.
## Verify the complete client flow
The root URL opens the Mempool explorer. `/health` and fee
estimates establish API availability; they do not prove that project discovery,
address history, or browser CORS works. Test a known funded project's address
history, its original Nostr announcement, the Explore page, and project details
in the actual Angor client. A certificate alone does not establish public routing.
Keep existing discovery relays when adding a new relay. A new relay has no
historical project data and does not automatically replicate other relays.
Even with existing relays, an empty Explore page can be a client discovery
failure: Angor Hub v2.0.0 was observed to stop after a batch whose announcements
all failed on-chain validation. The same failure reproduced with our indexer
and Angor's public indexer. Do not bypass the funding transaction's event-ID
commitment or substitute an unsigned announcement to make a project appear.
For opt-in read-only browser acceptance, install the frontend test dependencies
and Playwright Chromium, then run:
```sh
ANGOR_TEST_INDEXER=https://indexer.example.com/ \
ANGOR_TEST_RELAY=wss://relay.example.com/ \
ANGOR_TEST_RELAYS='["wss://relay.angor.io","wss://relay.example.com/"]' \
node tests/lifecycle/angor-public-browser.cjs
```
The relay under test must already contain the known original public project
announcement documented in the test. The test does not import events, send
funds, change your browser profile, or disable TLS verification. It checks the
funding transaction/event commitment and real browser discovery and details.
Relay signed writes, invalid-signature rejection, persistence, full node sync,
and proxy upgrade/renewal tests remain separate acceptance requirements.
## Packaging
Build the pinned image with:
```
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.2 apps/angor-indexer/container
```
The image runs as UID 101 with a read-only root filesystem and no capabilities.
Only temporary nginx state is writable. Runtime DNS is read from resolv.conf so
Mempool recreation does not require editing IP addresses or restarting this app.
No app-specific Rust installer is required.
Source documentation: [Angor's official deployment guide](https://github.com/block-core/angor/blob/869dd43cf38332dd7128a284a6bf4c1cac44c1a7/docker/DEPLOY-INDEXER-AND-RELAY.md).
The app icon is based on [Angor’s dark-mode app icon](https://angor.io/images/app-icon-dark-mode.png), retrieved 2026-09-30. At the operator’s request, the outer corners use the same green as the background. The built-in imagegen edit preserved the black mark and filled the square green; the project asset is `neode-ui/public/assets/img/app-icons/angor-green.png`.
Tests and release acceptance are recorded in the next-release checklist. The
health probe establishes backend availability, not a guarantee that every
address query is indexed at the latest Bitcoin tip.
Install Mempool Explorer first. The declarative `install_prerequisites` check
refuses a new adapter installation if its Mempool API component is absent, before
creating an installed-app record. It does not install or resync Bitcoin for you.
## Explorer on the public indexer origin
The linked official deployment guide exposes **Mempool frontend and API together**
on the public indexer URL. It uses standard Mempool images and requires no custom
Angor fork or `ANGOR_ENABLED` flag.
The operator now requires that same browser experience: opening the configured
indexer domain must show the existing Mempool explorer, while Angor API requests
continue working on that origin. Reuse the existing Mempool stack, including its
live WebSocket feed; do not install a second explorer or blockchain database.
**Candidate 1.0.2:** `/` and frontend paths proxy to the existing Mempool
frontend; `/api/`, `/api/v1/`, `/health` and the WebSocket feed retain their
indexer routes. Version 1.0.1 served only service JSON at `/`. The candidate
remains pending deployment/release acceptance, which must cover assets and deep links,
desktop/mobile rendering, WebSocket updates, API/CORS/broadcast, trusted HTTPS,
restart/upgrade and management-access isolation before documenting it as shipped.
+6
View File
@@ -0,0 +1,6 @@
FROM docker.io/library/nginx:1.31.3-alpine@sha256:1d40e3eb3bf4f138de1d67193f2aa5309fcaf343eb5ffadbf5e9439de1eb1ebb
COPY nginx.conf /etc/angor-nginx.conf.template
COPY entrypoint.sh /usr/local/bin/angor-indexer
USER 101:101
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/angor-indexer"]
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
# Resolve through the container runtime's DNS, including after dependency
# recreation. Never bake a container IP into the indexer endpoint.
DNS_RESOLVER=$(awk '/^nameserver[[:space:]]/ {print $2; exit}' /etc/resolv.conf)
case "$DNS_RESOLVER" in
''|*[!0-9a-fA-F.:]*) echo 'Container DNS resolver is unavailable' >&2; exit 1 ;;
esac
case "$DNS_RESOLVER" in *:*) DNS_RESOLVER="[$DNS_RESOLVER]" ;; esac
export DNS_RESOLVER
envsubst '${DNS_RESOLVER}' < /etc/angor-nginx.conf.template > /tmp/nginx.conf
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
+81
View File
@@ -0,0 +1,81 @@
worker_processes 1;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events { worker_connections 512; }
http {
access_log off;
server_tokens off;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
resolver ${DNS_RESOLVER} valid=10s ipv6=off;
upstream mempool_backend {
zone mempool_backend 64k;
server mempool-api:8999 resolve;
}
upstream mempool_frontend {
zone mempool_frontend 64k;
server mempool:8080 resolve;
}
map $http_upgrade $angor_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 8080;
client_max_body_size 4m;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 30s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection $angor_connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Authorization "";
proxy_set_header Cookie "";
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin '*' always;
add_header Access-Control-Allow-Methods 'GET, HEAD, POST, OPTIONS' always;
add_header Access-Control-Allow-Headers 'Content-Type' always;
add_header Cache-Control 'no-store' always;
if ($request_method = OPTIONS) { return 204; }
# Mempool's backend uses /api/v1. Match its frontend's shorter /api
# surface too, without doubling already-versioned Angor URLs.
rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last;
# Readiness checks the indexing backend, not this gateway's process.
location = /health {
limit_except GET { deny all; }
proxy_pass http://mempool_backend/api/v1/blocks/tip/height;
proxy_intercept_errors on;
error_page 500 502 503 504 =503 @waiting;
}
location @waiting {
default_type application/json;
return 503 '{"status":"waiting","message":"Waiting for Bitcoin and Mempool indexing"}\n';
}
location ~ ^/api/(v1/)?tx$ {
limit_except GET POST { deny all; }
proxy_pass http://mempool_backend;
}
location = /api/v1/ws {
limit_except GET { deny all; }
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_pass http://mempool_backend;
}
location /api/ {
limit_except GET { deny all; }
proxy_pass http://mempool_backend;
}
# Share the already-installed explorer; no second frontend or index DB.
# Its SPA handles transaction/block deep links and static assets.
location / {
limit_except GET { deny all; }
proxy_pass http://mempool_frontend;
proxy_intercept_errors on;
error_page 500 502 503 504 =503 @waiting;
}
}
}
+73
View File
@@ -0,0 +1,73 @@
app:
id: angor-indexer
name: Angor Indexer
version: 1.0.2
description: Bitcoin indexer endpoint for Angor with the existing Mempool explorer.
Reuses this node’s Mempool
and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s
address as the custom indexer in Angor settings. A relay is optional and installed
separately.
category: money
install_prerequisites:
- mempool
- mempool-api
upstream:
kind: github
repo: block-core/angor
container:
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.2
pull_policy: if-not-present
network: archy-net
dependencies:
- app_id: mempool
version: '>=3.0.0'
- app_id: mempool-api
version: '>=3.0.0'
- bitcoin:archival
resources:
cpu_limit: 1
memory_limit: 128Mi
disk_limit: 128Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 101
network_policy: isolated
ports:
- host: 8998
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: open
auth_rationale: Public Bitcoin chain-data API and validated transaction broadcast for Angor clients; no wallet keys or node RPC credentials are exposed. Browser cookie login would break machine clients.
interfaces:
main:
name: Angor Indexer API
description: Use this origin as Angor’s custom mainnet indexer URL, or open it
to view the existing Mempool explorer. HTTPS is required for browser clients.
type: api
port: 8998
protocol: http
path: /
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 8s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: true
pruning_support: false
metadata:
icon: /assets/img/app-icons/angor-green.png
tier: optional
repo: https://github.com/block-core/angor
features:
- Angor mainnet API
- Mempool explorer on the same origin
- Reuses existing Mempool indexing
- No separate blockchain database
- Optional independent relay
+33
View File
@@ -0,0 +1,33 @@
# Angor Relay
Optional standalone strfry relay for Angor's public project metadata. See
[Angor Indexer setup](../angor-indexer/README.md) for client URLs and HTTPS/WSS.
The gate exposes port 8091 for Nostr clients. strfry validates event signatures;
this is a public relay, not a private messaging archive. It mounts only
`/var/lib/archipelago/angor-relay` and its separate configuration directory.
It never opens, reconfigures or shares the node's internal strfry database.
For a public domain, proxy HTTPS to node port **8091**, enable WebSocket upgrade,
and add `wss://your-relay-domain/` in Angor. Test both NIP-11 metadata (send
`Accept: application/nostr+json`) and a real Nostr subscription over WSS. An
Archipelago login page at this domain is a routing failure, not relay readiness.
New relays start without project history. Keep existing discovery relays alongside
yours until the needed original signed announcements and metadata are available
locally. Relays do not automatically synchronize. Any history import must retain
the original event IDs and signatures; verify funded projects against their
on-chain commitments. A working WebSocket with zero stored events is not proof
that the client's project discovery works. See the indexer README's browser test.
The configuration is seeded only when absent, preserving operator changes.
Stop the service before making a consistent backup of its event database.
Ordinary start/restart/recreation preserves both mounts. Use the standard app
lifecycle; do not manually recreate a systemd-managed container.
## Image provenance
Mirrored from `docker.io/dockurr/strfry:1.1.2`, upstream manifest digest
`sha256:e81d238db13507f6ef24c49d47cd0b0ea58ff207961f10581fa2a7c901054df4`.
The public Angor policy is supplied by this app's own configuration; it does not
reuse the internal relay's event whitelist.
+223
View File
@@ -0,0 +1,223 @@
app:
id: angor-relay
name: Angor Relay
version: 1.1.2
upstream:
kind: github
repo: hoytech/strfry
description: Optional dedicated Nostr relay for Angor project metadata. Separate
storage and access settings keep the node’s internal relay private. Add this service’s
address to Angor’s relay settings; use WSS for browser clients.
container:
image: source.archipelago-foundation.org/chaum/angor-relay:1.1.2
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
seccomp_profile: default
network_policy: isolated
apparmor_profile: nostr-relay
ports:
- host: 8091
container: 7777
protocol: tcp
bind: 127.0.0.1
auth: open
auth_rationale: Dedicated public Nostr relay for Angor project metadata; strfry verifies event signatures. It has separate storage from the private node relay and no wallet or node credentials.
volumes:
- type: bind
source: /var/lib/archipelago/angor-relay
target: /app/strfry-db
options:
- rw
- type: bind
source: /var/lib/archipelago/angor-relay-config/angor-relay.conf
target: /etc/strfry.conf
options:
- ro
files:
- path: /var/lib/archipelago/angor-relay-config/angor-relay.conf
overwrite: false
content: |
##
## Default strfry config
##
# Directory that contains the strfry LMDB database (restart required)
db = "./strfry-db/"
dbParams {
# Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)
maxreaders = 256
# Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)
mapsize = 10995116277760
# Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)
noReadAhead = false
}
events {
# Maximum size of normalised JSON, in bytes
maxEventSize = 65536
# Events newer than this will be rejected
rejectEventsNewerThanSeconds = 900
# Events older than this will be rejected
rejectEventsOlderThanSeconds = 94608000
# Ephemeral events older than this will be rejected
rejectEphemeralEventsOlderThanSeconds = 60
# Ephemeral events will be deleted from the DB when older than this
ephemeralEventsLifetimeSeconds = 300
# Maximum number of tags allowed
maxNumTags = 2000
# Maximum size for tag values, in bytes
maxTagValSize = 1024
}
relay {
# Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)
bind = "0.0.0.0"
# Port to open for the nostr websocket protocol (restart required)
port = 7777
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
nofiles = 0
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
realIpHeader = ""
info {
# NIP-11: Name of this server. Short/descriptive (< 30 characters)
name = "Angor Relay"
# NIP-11: Detailed information about relay, free-form
description = "Dedicated public relay for Angor project metadata."
# NIP-11: Administrative nostr pubkey, for contact purposes
pubkey = ""
# NIP-11: Alternative administrative contact (email, website, etc)
contact = ""
# NIP-11: URL pointing to an image to be used as an icon for the relay
icon = ""
# List of supported lists as JSON array, or empty string to use default. Example: "[1,2]"
nips = ""
}
# Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)
maxWebsocketPayloadSize = 131072
# Maximum number of filters allowed in a REQ
maxReqFilterSize = 200
# Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)
autoPingSeconds = 55
# If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)
enableTcpKeepalive = false
# How much uninterrupted CPU time a REQ query should get during its DB scan
queryTimesliceBudgetMicroseconds = 10000
# Maximum records that can be returned per filter
maxFilterLimit = 500
# Maximum number of subscriptions (concurrent REQs) a connection can have open at any time
maxSubsPerConnection = 20
writePolicy {
# If non-empty, path to an executable script that implements the writePolicy plugin logic
plugin = ""
}
compression {
# Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)
enabled = true
# Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)
slidingWindow = true
}
logging {
# Dump all incoming messages
dumpInAll = false
# Dump all incoming EVENT messages
dumpInEvents = false
# Dump all incoming REQ/CLOSE messages
dumpInReqs = false
# Log performance metrics for initial REQ database scans
dbScanPerf = false
# Log reason for invalid event rejection? Can be disabled to silence excessive logging
invalidEvents = true
}
numThreads {
# Ingester threads: route incoming requests, validate events/sigs (restart required)
ingester = 3
# reqWorker threads: Handle initial DB scan for events (restart required)
reqWorker = 3
# reqMonitor threads: Handle filtering of new events (restart required)
reqMonitor = 3
# negentropy threads: Handle negentropy protocol messages (restart required)
negentropy = 2
}
negentropy {
# Support negentropy protocol messages
enabled = true
# Maximum records that sync will process before returning an error
maxSyncEvents = 1000000
}
}
health_check:
type: http
endpoint: http://127.0.0.1:7777
path: /health
interval: 30s
timeout: 5s
retries: 3
nostr_integration:
relay_type: public
monetization_enabled: false
category: nostr
interfaces:
main:
name: Angor Relay
description: Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your
HTTPS domain.
type: api
port: 8091
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/angor-green.png
tier: optional
repo: https://github.com/hoytech/strfry
features:
- Angor project metadata
- Separate from the node relay
- Persistent Nostr event storage
+80
View File
@@ -0,0 +1,80 @@
app:
id: archipelago-source
name: GitWorkshop
version: 0.4.0
upstream:
kind: github
repo: DanConwayDev/gitworkshop
description: >-
Get Archipelago's source, clone it with ngit, and contribute issues,
patches, and reviews over Nostr using the upstream GitWorkshop client.
category: development
container:
build:
context: /opt/archipelago/docker/archipelago-source
dockerfile: Dockerfile
tag: localhost/archipelago-source:local
resources:
cpu_limit: 1
memory_limit: 64Mi
disk_limit: 64Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: host
ports:
- host: 8337
container: 8337
protocol: tcp
bind: 127.0.0.1
auth: gated
session_passthrough: true
volumes:
- type: tmpfs
target: /tmp
tmpfs_options: rw,noexec,nosuid,size=16m,mode=1777
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8337
path: /healthz
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: GitWorkshop
description: NIP-34 repository browser, issues, pull requests, and review
type: ui
port: 8337
protocol: http
path: /
metadata:
# Versioned filename deliberately invalidates dashboard/browser icon caches
# when the Source prototype is replaced by the upstream GitWorkshop mark.
icon: /assets/img/app-icons/gitworkshop-dc36db6.svg
author: GitWorkshop contributors
repo: https://github.com/DanConwayDev/gitworkshop
maintainer_npub: npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg
tier: optional
launch:
# GitWorkshop is top-level in Companion's native in-app WebView. Its
# injected NIP-07 provider creates the authenticated dashboard-origin
# signer broker itself, so no dashboard parent frame is required.
requires_host_frame: false
features:
- NIP-34 repository discovery and browsing
- Bandwidth-efficient Git explorer over GRASP
- Nostr issues, pull requests, and code review
- NIP-07 extension and NIP-46 remote-signer support
- Archipelago node identity through explicit signing consent
+5 -3
View File
@@ -1,7 +1,7 @@
app: app:
id: archy-mempool-web id: archy-mempool-web
name: Mempool Web name: Mempool Web
version: 3.0.1 version: 3.3.1-archy1
# Where this app comes from, so scripts/check-upstream-releases.py can # Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can: # tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from. # container.image names our mirror, not the project it was mirrored from.
@@ -12,7 +12,7 @@ app:
container_name: mempool container_name: mempool
container: container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1 image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
@@ -45,7 +45,9 @@ app:
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets # first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop. # "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
endpoint: http://127.0.0.1:8080 endpoint: http://127.0.0.1:8080
path: / # Probe the backend through nginx: a static page can be healthy while
# every API/WebSocket request is stuck on a dead backend address.
path: /api/v1/backend-info
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
+1 -1
View File
@@ -54,7 +54,7 @@ app:
if [ -n "$RPC_TXRELAY_AUTH" ]; then if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi; fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+1 -1
View File
@@ -60,7 +60,7 @@ app:
if [ -n "$RPC_TXRELAY_AUTH" ]; then if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi; fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+67
View File
@@ -0,0 +1,67 @@
app:
id: cuprate-ui
name: Cuprate UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx
inside a container, serves a static status dashboard, and proxies
/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the
published host port for the container's 18089). No credentials are
injected — the restricted RPC is Monero's own safe-for-public subset — so
the nginx.conf is baked into the image and there is no rendered-config
bind-mount like bitcoin-ui's.
container:
build:
context: /opt/archipelago/docker/cuprate-ui
dockerfile: Dockerfile
tag: localhost/cuprate-ui:local
dependencies:
- app_id: cuprate
resources:
memory_limit: 64Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 18091 directly on the host IP.
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate would have no
# idea the port existed: neither protected nor listed as unprotected.
ports:
- host: 18091
container: 18091
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:18091
path: /
interval: 30s
timeout: 5s
retries: 3
metadata:
icon: /assets/img/app-icons/cuprate.svg
category: money
tier: optional
author: Archipelago
repo: https://github.com/Cuprate/cuprate
+23 -7
View File
@@ -36,12 +36,26 @@ app:
data_uid: "1000:1000" data_uid: "1000:1000"
dependencies: dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a # Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
# month; cuprated's pruning support is not confirmed stable yet (the # Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
# `pruning` crate exists in the workspace but nothing in this config # cuprated has NO on-disk pruning setting of any kind — the `pruning`
# surface toggles it), so this sizes for a full unpruned chain plus # crate in its workspace is Monero's p2p *protocol* pruning, not a
# headroom rather than assuming pruning is available. # smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
- storage: 300Gi # when disk is scarce (see the DISK_GB branch in
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
# syncs until the filesystem fills and takes Archipelago down. The
# disk-scarce equivalent is enforced in Rust instead: install, start,
# restart and update refuse, and boot reconcile skips, on any node under
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
# replace that gate with the bitcoin-style entrypoint branch.
#
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
# (store size display, install pre-checks, docs) must show the number the
# Rust gate actually enforces, or a user provisioned to the displayed
# value gets refused at a different, unexplained one. Single source of
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
- storage: 450Gi
resources: resources:
cpu_limit: 0 cpu_limit: 0
@@ -51,7 +65,9 @@ app:
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves # CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
# headroom above the 8GiB cache for the process itself. # headroom above the 8GiB cache for the process itself.
memory_limit: 10Gi memory_limit: 10Gi
disk_limit: 300Gi # Matches the storage dependency above (= the enforced disk floor),
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
disk_limit: 450Gi
security: security:
# FROM scratch, no package manager/shell, ownership fixed at build time # FROM scratch, no package manager/shell, ownership fixed at build time
+25 -10
View File
@@ -15,15 +15,20 @@ app:
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3 image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
pull_policy: if-not-present pull_policy: if-not-present
# Preserve repositories, database, keys and configuration during runtime repairs.
backup_before_runtime_change: true
dependencies: dependencies:
- storage: 500Mi # Source history, LFS objects, release artifacts and OCI layers all share
# this persistent store. 500Mi was only suitable for an empty demo node.
- storage: 50Gi
resources: resources:
memory_limit: 256Mi memory_limit: 256Mi
disk_limit: 500Mi disk_limit: 50Gi
security: security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE] capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, SYS_CHROOT]
readonly_root: false readonly_root: false
no_new_privileges: false no_new_privileges: false
network_policy: bridge network_policy: bridge
@@ -60,12 +65,29 @@ app:
target: /etc/gitea target: /etc/gitea
options: [rw] options: [rw]
# Seed a fresh installation with the same origin advertised by the app gate.
# Existing app.ini (including custom HTTPS/domain settings) is never replaced.
files:
- path: /var/lib/archipelago/gitea/data/gitea/conf/app.ini
overwrite: false
content: |
[server]
DOMAIN = {{HOST_IP}}
SSH_DOMAIN = {{HOST_IP}}
ROOT_URL = http://{{HOST_IP}}:3001/
environment: environment:
- GITEA__database__DB_TYPE=sqlite3 - GITEA__database__DB_TYPE=sqlite3
- GITEA__server__SSH_PORT=2222 - GITEA__server__SSH_PORT=2222
- GITEA__server__SSH_LISTEN_PORT=22 - GITEA__server__SSH_LISTEN_PORT=22
- GITEA__server__LFS_START_SERVER=true - GITEA__server__LFS_START_SERVER=true
- GITEA__packages__ENABLED=true - GITEA__packages__ENABLED=true
# Package/LFS storage remains bounded by the node's disk, not an arbitrary
# per-owner quota. Release artifacts allow installer/OTA images up to 10GiB.
- GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1
- GITEA__packages__LIMIT_SIZE_CONTAINER=-1
- GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240
- GITEA__repository_0x2Erelease__MAX_FILES=20
- GITEA__repository__ENABLE_PUSH_CREATE_USER=true - GITEA__repository__ENABLE_PUSH_CREATE_USER=true
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true - GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
@@ -98,10 +120,3 @@ app:
- Issue tracking and pull requests - Issue tracking and pull requests
- CI/CD via Gitea Actions - CI/CD via Gitea Actions
- Lightweight SQLite deployment - Lightweight SQLite deployment
nginx_proxy:
listen: 3000
proxy_pass: http://127.0.0.1:3001
extra_headers:
- proxy_hide_header X-Frame-Options
- proxy_hide_header Content-Security-Policy
+10 -8
View File
@@ -15,18 +15,21 @@ app:
container_name: indeedhub-api container_name: indeedhub-api
container: container:
# Public identity pins only; registration/publication stay disabled by default.
media_registration_identity: true
image: source.archipelago-foundation.org/lfg2025/indeedhub-api:1.0.0 image: source.archipelago-foundation.org/lfg2025/indeedhub-api:1.0.0
pull_policy: if-not-present pull_policy: if-not-present
network: indeedhub-net network: indeedhub-net
network_aliases: [api] network_aliases: [api]
# The JWT signing secret is owned here (no backend container owns it); the # The JWT signing secret and stable envelope-encryption root are owned here;
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and # the db + minio passwords are owned by indeedhub-postgres / indeedhub-minio
# only consumed here. ensure_generated_secrets no-ops when a file already # and only consumed here. Existing nodes migrate the legacy AES value into
# exists, so live values on .228 are preserved (postgres pw is fixed at # the secret file once, while fresh nodes receive a unique per-node value.
# PGDATA init — regenerating would lock the API out).
generated_secrets: generated_secrets:
- name: indeedhub-jwt - name: indeedhub-jwt
kind: hex32 kind: hex32
- name: indeedhub-aes-master
kind: hex16
secret_env: secret_env:
- key: DATABASE_PASSWORD - key: DATABASE_PASSWORD
secret_file: indeedhub-db-password secret_file: indeedhub-db-password
@@ -34,6 +37,8 @@ app:
secret_file: indeedhub-minio-password secret_file: indeedhub-minio-password
- key: NOSTR_JWT_SECRET - key: NOSTR_JWT_SECRET
secret_file: indeedhub-jwt secret_file: indeedhub-jwt
- key: AES_MASTER_SECRET
secret_file: indeedhub-aes-master
dependencies: dependencies:
- app_id: indeedhub-postgres - app_id: indeedhub-postgres
@@ -67,9 +72,6 @@ app:
- S3_PRIVATE_BUCKET_NAME=indeedhub-private - S3_PRIVATE_BUCKET_NAME=indeedhub-private
- S3_PUBLIC_BUCKET_URL=/storage - S3_PUBLIC_BUCKET_URL=/storage
- NOSTR_JWT_EXPIRES_IN=7d - NOSTR_JWT_EXPIRES_IN=7d
# Fixed across the fleet (envelope-encryption master key baked by the legacy
# installer); not node-specific, so a plain env literal, not a secret.
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
- ENVIRONMENT=production - ENVIRONMENT=production
health_check: health_check:
+2 -1
View File
@@ -22,6 +22,8 @@ app:
secret_file: indeedhub-db-password secret_file: indeedhub-db-password
- key: AWS_SECRET_KEY - key: AWS_SECRET_KEY
secret_file: indeedhub-minio-password secret_file: indeedhub-minio-password
- key: AES_MASTER_SECRET
secret_file: indeedhub-aes-master
dependencies: dependencies:
- app_id: indeedhub-api - app_id: indeedhub-api
@@ -51,4 +53,3 @@ app:
- S3_PUBLIC_BUCKET_NAME=indeedhub-public - S3_PUBLIC_BUCKET_NAME=indeedhub-public
- S3_PRIVATE_BUCKET_NAME=indeedhub-private - S3_PRIVATE_BUCKET_NAME=indeedhub-private
- ENVIRONMENT=production - ENVIRONMENT=production
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
+4 -1
View File
@@ -69,7 +69,10 @@ app:
- copy_from_host: - copy_from_host:
src: "web-ui/nostr-provider.js" src: "web-ui/nostr-provider.js"
dest: "/usr/share/nginx/html/nostr-provider.js" dest: "/usr/share/nginx/html/nostr-provider.js"
- exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /etc/nginx/conf.d/default.conf"] - exec: ["sh", "-c", "grep -qF 'location = /nostr-provider.js {' /etc/nginx/conf.d/default.conf || sed -i '/location = .*sw[.]js {/i\\ location = /nostr-provider.js {\\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\\n expires off;\\n }\\n' /etc/nginx/conf.d/default.conf"]
- exec: ["sh", "-c", "if ! grep -qE '<script[^>]*nostr-provider' /usr/share/nginx/html/index.html && ! grep -qE '(sub_filter|<script).*nostr-provider' /etc/nginx/conf.d/default.conf; then sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /usr/share/nginx/html/index.html; fi"]
- exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"]
- exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"]
- exec: ["nginx", "-s", "reload"] - exec: ["nginx", "-s", "reload"]
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at # TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: mempool id: mempool
name: Mempool Explorer name: Mempool Explorer
version: 3.0.0 version: 3.3.1-archy1
# Where this app comes from, so scripts/check-upstream-releases.py can # Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can: # tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization. description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container: container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1 image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
image_signature: cosign://... image_signature: cosign://...
pull_policy: if-not-present pull_policy: if-not-present
+24 -8
View File
@@ -1,20 +1,23 @@
app: app:
id: nginx-proxy-manager id: nginx-proxy-manager
name: Nginx Proxy Manager name: Nginx Proxy Manager
version: 2.12.1 version: 2.14.0
upstream: upstream:
kind: github kind: github
repo: NginxProxyManager/nginx-proxy-manager repo: NginxProxyManager/nginx-proxy-manager
description: >- description: >-
Reverse proxy with SSL. Beautiful web interface for managing proxies. Reverse proxy with SSL. Beautiful web interface for managing proxies.
On a node, this manages its admin UI and upstream configuration — the The node's public web server forwards configured domains through this
proxy's own :80/:443 listeners are not published (the node's web server service, preserving its access lists, certificates and custom routes.
owns those ports). backup_before_runtime_change: true
container: container:
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08
pull_policy: if-not-present pull_policy: if-not-present
network: pasta # Rootless pasta copies the LAN IP, preventing requests back to this node.
# Retain the old pasta host gateway used by saved NPM upstreams, plus
# host.containers.internal. This subnet stays inside the private rootless namespace.
network: slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24
dependencies: dependencies:
- storage: 1Gi - storage: 1Gi
@@ -49,6 +52,17 @@ app:
Nginx Proxy Manager enforces its own admin account on every page; Nginx Proxy Manager enforces its own admin account on every page;
the initial setup wizard also has to answer before any account exists. the initial setup wizard also has to answer before any account exists.
- host: 8088
container: 80
protocol: tcp
bind: 127.0.0.1
auth: local
- host: 8444
container: 443
protocol: tcp
bind: 127.0.0.1
auth: local
volumes: volumes:
- type: bind - type: bind
source: /var/lib/archipelago/nginx-proxy-manager source: /var/lib/archipelago/nginx-proxy-manager
@@ -64,9 +78,11 @@ app:
environment: [] environment: []
# Probe the admin API inside the container, independent of optional
# tunnel listeners. This also verifies the Node backend is ready.
health_check: health_check:
type: tcp type: http
endpoint: localhost:81 endpoint: http://127.0.0.1:81/api/
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
+8
View File
@@ -14,8 +14,16 @@ app:
container: container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0 image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
pull_policy: if-not-present pull_policy: if-not-present
# Portainer fetches Git sources and images from services on this same node.
# Rootless pasta copies the host LAN address into its namespace, so a LAN
# URL points back at Portainer itself. Give it a private address with the
# supported rootless slirp backend; public app URLs still traverse the gate.
network: slirp4netns
data_uid: "1000:1000" data_uid: "1000:1000"
# Snapshot state before an upgrade recreates this app with new networking.
backup_before_runtime_change: true
dependencies: dependencies:
- storage: 1Gi - storage: 1Gi
+2 -2
View File
@@ -67,13 +67,13 @@
{ {
"id": "mempool", "id": "mempool",
"title": "Mempool Explorer", "title": "Mempool Explorer",
"version": "3.0.0", "version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.", "description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp", "icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool", "author": "Mempool",
"category": "money", "category": "money",
"tier": "core", "tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1", "dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool", "repoUrl": "https://github.com/mempool/mempool",
"requires": [ "requires": [
"bitcoin-knots", "bitcoin-knots",
+64 -5
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.11-alpha" version = "1.9.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
@@ -137,9 +137,11 @@ dependencies = [
"hyper 0.14.32", "hyper 0.14.32",
"hyper-util", "hyper-util",
"hyper-ws-listener", "hyper-ws-listener",
"image",
"iroh", "iroh",
"iroh-blobs", "iroh-blobs",
"libc", "libc",
"lightning-invoice",
"lofty", "lofty",
"mainline", "mainline",
"mdns-sd", "mdns-sd",
@@ -1567,6 +1569,15 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "fiat-crypto" name = "fiat-crypto"
version = "0.2.9" version = "0.2.9"
@@ -2503,8 +2514,22 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
] ]
[[package]] [[package]]
@@ -3636,9 +3661,9 @@ dependencies = [
[[package]] [[package]]
name = "nostr" name = "nostr"
version = "0.44.2" version = "0.44.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9"
dependencies = [ dependencies = [
"aes", "aes",
"base64 0.22.1", "base64 0.22.1",
@@ -3681,9 +3706,9 @@ dependencies = [
[[package]] [[package]]
name = "nostr-relay-pool" name = "nostr-relay-pool"
version = "0.44.0" version = "0.44.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"async-wsocket", "async-wsocket",
@@ -4142,6 +4167,19 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "poly1305" name = "poly1305"
version = "0.8.0" version = "0.8.0"
@@ -4366,6 +4404,12 @@ dependencies = [
"image", "image",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.39.4"
@@ -7322,3 +7366,18 @@ dependencies = [
"log", "log",
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+9 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.11-alpha" version = "1.9.0-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
@@ -73,6 +73,7 @@ chrono = "0.4"
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation # BIP-39 mnemonic seed generation + BIP-32 HD key derivation
bip39 = { version = "2.1", features = ["rand"] } bip39 = { version = "2.1", features = ["rand"] }
lightning-invoice = "=0.34.1"
bitcoin = { version = "=0.32.5", features = ["rand-std"] } bitcoin = { version = "=0.32.5", features = ["rand-std"] }
# Configuration # Configuration
@@ -90,8 +91,9 @@ rustls-pemfile = "1.0"
webpki = { package = "rustls-webpki", version = "0.101" } webpki = { package = "rustls-webpki", version = "0.101" }
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] } reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake) # Nostr (node discovery + NIP-44 encrypted peer handshake).
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] } # nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow.
nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] }
# Backup encryption (DID identity export) + TOTP 2FA encryption # Backup encryption (DID identity export) + TOTP 2FA encryption
argon2 = "0.5.3" argon2 = "0.5.3"
@@ -105,6 +107,8 @@ flate2 = "1.0"
# TOTP 2FA # TOTP 2FA
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] } totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
qrcode = "0.14" qrcode = "0.14"
# Paid image previews must be degraded on the server, never by browser CSS.
image = { version = "0.25.9", default-features = false, features = ["jpeg", "png", "webp"] }
data-encoding = "2.6" data-encoding = "2.6"
zeroize = { version = "1.8.2", features = ["derive"] } zeroize = { version = "1.8.2", features = ["derive"] }
@@ -145,6 +149,7 @@ iroh-blobs = { version = "0.103", optional = true }
lofty = "0.24.0" lofty = "0.24.0"
cashu = { version = "0.17.5", default-features = false, features = ["wallet"] } cashu = { version = "0.17.5", default-features = false, features = ["wallet"] }
tempfile = "3.10"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4"
tempfile = "3.10"
@@ -0,0 +1,142 @@
//! Permanent Cloud snapshot delivery. Current source path/share state cannot
//! revoke a settled immutable snapshot; no rental clock is started here.
use super::{build_response, ApiHandler};
use crate::{
content_purchase::{Journal, SellerPhase},
content_server::ByteRange,
};
use anyhow::{Context, Result};
use hyper::{Body, HeaderMap, Response, StatusCode};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
impl ApiHandler {
pub(super) async fn handle_cloud_purchase(
&self,
path: &str,
headers: &HeaderMap,
) -> Result<Response<Body>> {
let (content_id, purchase_id) = path
.strip_prefix("/content/")
.and_then(|value| value.split_once("/purchase/"))
.context("Invalid purchase delivery route")?;
anyhow::ensure!(
!content_id.contains('/')
&& !content_id.starts_with("registered_")
&& !purchase_id.contains('/'),
"Invalid Cloud delivery route"
);
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let buyer = crate::content_auth::incoming(
headers,
&audience,
path,
chrono::Utc::now().timestamp(),
)?
.context("Authenticated peer proof is required")?;
let mut values = headers.get_all("x-content-capability").iter();
let capability = values
.next()
.context("Delivery capability is required")?
.to_str()?;
anyhow::ensure!(values.next().is_none(), "Duplicate delivery capability");
let (contract, mime) = {
let journal = Journal::open(&self.config.data_dir).await?;
let record = journal
.seller(purchase_id)
.await?
.context("Purchase settlement not found")?;
let receipt = match record.phase {
SellerPhase::ReceiptSaved(receipt) => receipt,
_ => anyhow::bail!("Purchase settlement is not durable"),
};
anyhow::ensure!(
record.contract.buyer_did == buyer
&& record.contract.seller_did == audience
&& record.contract.content_id == content_id
&& receipt.capability == capability,
"Purchase delivery binding changed"
);
let envelope = journal
.protocol_envelope("seller", purchase_id)
.await?
.context("Original delivery metadata is missing")?;
anyhow::ensure!(
envelope.contract()? == record.contract,
"Delivery metadata binding changed"
);
(record.contract, envelope.offer.mime_type)
};
let range = headers
.get("range")
.map(|value| -> Result<_> {
crate::content_server::parse_range_header(value.to_str()?).context("Invalid range")
})
.transpose()?;
let total = contract.content_size;
let (start, end, partial) = match range {
None => (0, total - 1, false),
Some(ByteRange::From { start, end }) => {
(start, end.unwrap_or(total - 1).min(total - 1), true)
}
Some(ByteRange::Suffix(count)) if count > 0 => {
(total.saturating_sub(count), total - 1, true)
}
_ => anyhow::bail!("Invalid range"),
};
if start > end || start >= total {
let mut response = build_response(
StatusCode::RANGE_NOT_SATISFIABLE,
"text/plain",
Body::empty(),
);
response
.headers_mut()
.insert("content-range", format!("bytes */{total}").parse()?);
return Ok(response);
}
let data = self.config.data_dir.clone();
let file = tokio::task::spawn_blocking(move || {
crate::content_snapshot::open_matching(
&data,
&contract.content_id,
&contract.content_sha256,
contract.content_size,
)
})
.await??;
let mut file = tokio::fs::File::from_std(file.file);
file.seek(std::io::SeekFrom::Start(start)).await?;
let length = end - start + 1;
let chunks =
futures_util::stream::try_unfold((file, length), |(mut file, left)| async move {
if left == 0 {
return Ok::<_, std::io::Error>(None);
}
let mut bytes = vec![0; left.min(65536) as usize];
let count = file.read(&mut bytes).await?;
if count == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Purchase snapshot ended early",
));
}
bytes.truncate(count);
Ok(Some((bytes, (file, left - count as u64))))
});
let mut response = Response::builder()
.status(if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
})
.header("content-type", mime)
.header("content-length", length)
.header("accept-ranges", "bytes")
.header("cache-control", "private, no-store")
.header("x-content-type-options", "nosniff")
.header("content-security-policy", "sandbox; default-src 'none'");
if partial {
response = response.header("content-range", format!("bytes {start}-{end}/{total}"));
}
Ok(response.body(Body::wrap_stream(chunks))?)
}
}
+357 -91
View File
@@ -7,14 +7,48 @@ use hyper::{Response, StatusCode};
use super::{is_valid_app_id, ApiHandler}; use super::{is_valid_app_id, ApiHandler};
impl ApiHandler { impl ApiHandler {
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> { fn verified_content_peer(
match content_server::load_catalog(&config.data_dir).await { &self,
path: &str,
headers: &hyper::HeaderMap,
) -> Result<Option<String>> {
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
crate::content_auth::incoming(headers, &audience, path, chrono::Utc::now().timestamp())
}
async fn content_access_context(
&self,
path: &str,
headers: &hyper::HeaderMap,
) -> Result<(Option<String>, bool, bool)> {
let peer = self.verified_content_peer(path, headers)?;
let known = if let Some(did) = &peer {
crate::federation::load_nodes(&self.config.data_dir)
.await?
.iter()
.any(|node| &node.did == did)
} else {
false
};
let owner = match crate::session::extract_session_cookie(headers) {
Some(token) => self.session_store.validate(&token).await,
None => false,
};
Ok((peer, known, owner))
}
pub(super) async fn handle_content_catalog(
&self,
headers: &hyper::HeaderMap,
) -> Result<Response<hyper::Body>> {
let (peer, known, owner) = self.content_access_context("/content", headers).await?;
match content_server::load_catalog(&self.config.data_dir).await {
Ok(catalog) => { Ok(catalog) => {
// Only expose public metadata for available items // Only expose public metadata for available items
let items: Vec<serde_json::Value> = catalog let items: Vec<serde_json::Value> = catalog
.items .items
.iter() .iter()
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody)) .filter(|item| content_server::visible_to(item, peer.as_deref(), known, owner))
.map(|i| { .map(|i| {
serde_json::json!({ serde_json::json!({
"id": i.id, "id": i.id,
@@ -74,7 +108,7 @@ impl ApiHandler {
let invoice_hash = headers let invoice_hash = headers
.get("x-invoice-hash") .get("x-invoice-hash")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.map(|s| s.to_string()) .map(|s| s.to_ascii_lowercase())
.or_else(|| { .or_else(|| {
headers headers
.get("x-onchain-address") .get("x-onchain-address")
@@ -82,11 +116,18 @@ impl ApiHandler {
.map(|s| s.to_string()) .map(|s| s.to_string())
}); });
// Extract federation peer DID from X-Federation-DID header let peer_did = match self.verified_content_peer(path, headers) {
let peer_did = headers Ok(peer) => peer,
.get("x-federation-did") Err(_) => {
.and_then(|v| v.to_str().ok()) return Ok(build_response(
.map(|s| s.to_string()); StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(
r#"{"error":"Peer authentication failed. Check both nodes are updated and their clocks are correct."}"#,
),
))
}
};
// The authenticated local operator never pays for their own node's // The authenticated local operator never pays for their own node's
// content: validate the session cookie (same discipline as the model // content: validate the session cookie (same discipline as the model
@@ -98,11 +139,64 @@ impl ApiHandler {
None => false, None => false,
}; };
// Payment settlement is verified on the seller even when no status
// poll preceded this download (e.g. direct payment from another node).
let requires_payment = if !owner_session && headers.contains_key("x-invoice-hash") {
content_server::load_catalog(&config.data_dir)
.await?
.items
.iter()
.any(|item| {
item.id == content_id
&& matches!(item.access, content_server::AccessControl::Paid { .. })
})
} else {
false
};
if requires_payment {
if let Some(hash) = headers.get("x-invoice-hash").and_then(|v| v.to_str().ok()) {
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid payment hash"),
));
}
if let Err(error) = self
.rpc_handler
.settle_content_invoice(hash, content_id)
.await
{
tracing::warn!("Cannot verify peer-file invoice settlement: {error:#}");
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"Payment verification is temporarily unavailable. Retry the download without paying again."}"#,
),
));
}
}
}
// Parse Range header for streaming support // Parse Range header for streaming support
let range = headers let range = match headers.get("range") {
.get("range") None => None,
.and_then(|v| v.to_str().ok()) Some(value) => match value
.and_then(content_server::parse_range_header); .to_str()
.ok()
.and_then(content_server::parse_range_header)
{
Some(range) => Some(range),
None => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid byte range"),
))
}
},
};
match content_server::serve_content( match content_server::serve_content(
&config.data_dir, &config.data_dir,
@@ -115,6 +209,7 @@ impl ApiHandler {
) )
.await .await
{ {
Ok(content_server::ServeResult::Stream(body)) => body.into_response(),
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => { Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
let len = bytes.len(); let len = bytes.len();
Ok(Response::builder() Ok(Response::builder()
@@ -162,18 +257,44 @@ impl ApiHandler {
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#, r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
), ),
)), )),
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response( Ok(content_server::ServeResult::Unavailable) => Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"The seller's node can't read this file right now. This request did not redeem an ecash payment."}"#,
),
)),
Ok(content_server::ServeResult::RangeNotSatisfiable(total)) => Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", format!("bytes */{total}"))
.body(hyper::Body::empty())
.unwrap()),
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"text/plain", "text/plain",
hyper::Body::from("Content not found"), hyper::Body::from("Content not found"),
)), )),
// Not a 404: a paid request may already have been charged by the
// time this fails, and "not found" hid the real error entirely.
Err(e) => {
tracing::error!("Serving content {content_id} failed: {e:#}");
Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"text/plain",
hyper::Body::from("Failed to serve content"),
))
}
} }
} }
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a /// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice. /// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
/// Records a pending entitlement keyed by the invoice's payment hash. /// Records a pending entitlement keyed by the invoice's payment hash.
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> { pub(super) async fn handle_content_invoice(
&self,
path: &str,
headers: &hyper::HeaderMap,
) -> Result<Response<hyper::Body>> {
let content_id = path let content_id = path
.strip_prefix("/content/") .strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/invoice")) .and_then(|s| s.strip_suffix("/invoice"))
@@ -186,6 +307,7 @@ impl ApiHandler {
)); ));
} }
let (peer, known, owner) = self.content_access_context(path, headers).await?;
let catalog = content_server::load_catalog(&self.config.data_dir) let catalog = content_server::load_catalog(&self.config.data_dir)
.await .await
.unwrap_or_default(); .unwrap_or_default();
@@ -199,6 +321,13 @@ impl ApiHandler {
)) ))
} }
}; };
if !content_server::visible_to(item, peer.as_deref(), known, owner) {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
));
}
let price_sats = match &item.access { let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats, .. } => *price_sats, content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
_ => { _ => {
@@ -220,6 +349,18 @@ impl ApiHandler {
)); ));
} }
if let Err(error) =
content_server::ensure_payment_source_available(&self.config.data_dir, item).await
{
return Ok(build_response(
StatusCode::CONFLICT,
"application/json",
hyper::Body::from(serde_json::to_vec(
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
)?),
));
}
let memo = format!("Archipelago peer file {content_id}"); let memo = format!("Archipelago peer file {content_id}");
match self match self
.rpc_handler .rpc_handler
@@ -227,7 +368,13 @@ impl ApiHandler {
.await .await
{ {
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => { Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await; crate::content_invoice::record_pending(
&self.config.data_dir,
&payment_hash,
content_id,
price_sats,
)
.await?;
let body = serde_json::json!({ let body = serde_json::json!({
"bolt11": bolt11, "bolt11": bolt11,
"payment_hash": payment_hash, "payment_hash": payment_hash,
@@ -268,58 +415,20 @@ impl ApiHandler {
&self, &self,
path: &str, path: &str,
) -> Result<Response<hyper::Body>> { ) -> Result<Response<hyper::Body>> {
let rest = path.strip_prefix("/content/").unwrap_or(""); Ok(invoice_status_response(path, |hash, id| async move {
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") { self.rpc_handler.content_invoice_lifecycle(&hash, &id).await
Some((id, hash)) => (id, hash), })
None => { .await)
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
))
}
};
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
));
}
// The hash must be one we issued for exactly this content item.
match crate::content_invoice::lookup(payment_hash).await {
Some((cid, _)) if cid == content_id => {}
_ => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
))
}
}
// Already paid? Otherwise ask our LND and persist the result.
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
crate::content_invoice::mark_paid(payment_hash).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid });
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
} }
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item /// Seller side (#46): issue a fresh on-chain address for a paid catalog item
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a /// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
/// pending entitlement keyed by the address; price doubles as expected amount. /// pending entitlement keyed by the address; price doubles as expected amount.
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> { pub(super) async fn handle_content_onchain(
&self,
path: &str,
headers: &hyper::HeaderMap,
) -> Result<Response<hyper::Body>> {
let content_id = path let content_id = path
.strip_prefix("/content/") .strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/onchain")) .and_then(|s| s.strip_suffix("/onchain"))
@@ -331,43 +440,80 @@ impl ApiHandler {
hyper::Body::from("Invalid content ID"), hyper::Body::from("Invalid content ID"),
)); ));
} }
let (peer, known, owner) = self.content_access_context(path, headers).await?;
let catalog = content_server::load_catalog(&self.config.data_dir) let catalog = content_server::load_catalog(&self.config.data_dir)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) { let Some(item) = catalog.items.iter().find(|item| item.id == content_id) else {
Some(i) => match &i.access { return Ok(build_response(
content_server::AccessControl::Paid { price_sats, .. } => { StatusCode::NOT_FOUND,
if !content_server::method_accepted(&i.access, "onchain") { "text/plain",
return Ok(build_response( hyper::Body::from("Content not found"),
StatusCode::BAD_REQUEST, ));
"application/json", };
hyper::Body::from( if !content_server::visible_to(item, peer.as_deref(), known, owner) {
r#"{"error":"The seller does not accept on-chain payment for this item"}"#, return Ok(build_response(
), StatusCode::NOT_FOUND,
)); "text/plain",
} hyper::Body::from("Content not found"),
*price_sats ));
} }
_ => { let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats, .. } => {
if !content_server::method_accepted(&item.access, "onchain") {
return Ok(build_response( return Ok(build_response(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"application/json", "application/json",
hyper::Body::from(r#"{"error":"Item is not paid"}"#), hyper::Body::from(
)) r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
),
));
} }
}, *price_sats
None => { }
_ => {
return Ok(build_response( return Ok(build_response(
StatusCode::NOT_FOUND, StatusCode::BAD_REQUEST,
"text/plain", "application/json",
hyper::Body::from("Content not found"), hyper::Body::from(r#"{"error":"Item is not paid"}"#),
)) ))
} }
}; };
// Match the node wallet's existing sendcoins minimum before exposing a
// payable address for an amount its own payment flow cannot broadcast.
if let Err(error) = content_server::validate_onchain_payment_price(price_sats) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(serde_json::to_vec(
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
)?),
));
}
if let Err(error) =
content_server::ensure_payment_source_available(&self.config.data_dir, item).await
{
return Ok(build_response(
StatusCode::CONFLICT,
"application/json",
hyper::Body::from(serde_json::to_vec(
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
)?),
));
}
match self.rpc_handler.new_onchain_address().await { match self.rpc_handler.new_onchain_address().await {
Ok(address) if !address.is_empty() => { Ok(address) if !address.is_empty() => {
crate::content_invoice::record_pending(&address, content_id, price_sats).await; crate::content_invoice::record_pending_method(
&self.config.data_dir,
&address,
content_id,
price_sats,
crate::content_invoice::PaymentMethod::Onchain,
)
.await?;
let body = serde_json::json!({ let body = serde_json::json!({
"address": address, "address": address,
"amount_sats": price_sats, "amount_sats": price_sats,
@@ -417,7 +563,7 @@ impl ApiHandler {
)); ));
} }
// The address must be one we issued for exactly this content item. // The address must be one we issued for exactly this content item.
let price = match crate::content_invoice::lookup(address).await { let price = match crate::content_invoice::lookup(&self.config.data_dir, address).await? {
Some((cid, price)) if cid == content_id => price, Some((cid, price)) if cid == content_id => price,
_ => { _ => {
return Ok(build_response( return Ok(build_response(
@@ -428,11 +574,24 @@ impl ApiHandler {
} }
}; };
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await; let mut paid =
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
if !paid { if !paid {
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await { match self.rpc_handler.onchain_received(address, price).await {
crate::content_invoice::mark_paid(address).await; Ok(true) => {
paid = true; crate::content_invoice::mark_paid(&self.config.data_dir, address).await?;
paid = true;
}
Ok(false) => {}
Err(_) => return Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&serde_json::json!({
"paid": false,
"status": "unknown",
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
}))?),
)),
} }
} }
let body = serde_json::json!({ "paid": paid }); let body = serde_json::json!({ "paid": paid });
@@ -463,6 +622,7 @@ impl ApiHandler {
} }
match content_server::serve_content_preview(&config.data_dir, content_id).await { match content_server::serve_content_preview(&config.data_dir, content_id).await {
Ok(content_server::PreviewResult::Stream(body)) => body.into_response(),
Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => { Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => {
let len = bytes.len(); let len = bytes.len();
Ok(Response::builder() Ok(Response::builder()
@@ -509,3 +669,109 @@ impl ApiHandler {
} }
} }
} }
/// Keep invalid input and an unavailable wallet inside the HTTP protocol so
/// buyers can retry delivery without treating a dropped socket as lost payment.
async fn invoice_status_response<F, Fut>(path: &str, settle: F) -> Response<hyper::Body>
where
F: FnOnce(String, String) -> Fut,
Fut: std::future::Future<Output = Result<serde_json::Value>>,
{
let parsed = path
.strip_prefix("/content/")
.and_then(|rest| rest.split_once("/invoice-status/"))
.filter(|(id, hash)| {
!id.is_empty()
&& is_valid_app_id(id)
&& hash.len() == 64
&& hash.bytes().all(|c| c.is_ascii_hexdigit())
});
let Some((id, hash)) = parsed else {
return build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Invalid content ID or payment hash"}"#),
);
};
match settle(hash.to_ascii_lowercase(), id.to_owned()).await {
Ok(body) => build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body.to_string()),
),
Err(_) => {
tracing::warn!("Peer-file payment status verification is temporarily unavailable");
let mut response = build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"Payment verification is temporarily unavailable. Retry without paying again."}"#,
),
);
response.headers_mut().insert(
hyper::header::RETRY_AFTER,
hyper::header::HeaderValue::from_static("5"),
);
response
}
}
}
#[cfg(test)]
mod invoice_status_tests {
use super::*;
#[tokio::test]
async fn malformed_requests_do_not_query_the_wallet() {
for path in [
"/bad",
"/content//invoice-status/aa",
"/content/file/invoice-status/aa",
"/content/file/invoice-status/",
"/content/file/invoice-status/not-a-hash",
] {
let response = invoice_status_response(path, |_, _| async {
panic!("Invalid request reached wallet");
#[allow(unreachable_code)]
Ok(serde_json::json!({"paid":false}))
})
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(response.headers()["content-type"], "application/json");
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
assert!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["error"].is_string()
);
}
}
#[tokio::test]
async fn settlement_results_and_failures_have_explicit_http_responses() {
let hash = "AB".repeat(32);
let path = format!("/content/file/invoice-status/{hash}");
for paid in [false, true] {
let response = invoice_status_response(&path, |hash, id| async move {
assert_eq!(hash, "ab".repeat(32));
assert_eq!(id, "file");
Ok(serde_json::json!({"paid":paid}))
})
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["paid"],
paid
);
}
let response = invoice_status_response(&path, |_, _| async {
anyhow::bail!("private wallet details must not escape")
})
.await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(response.headers()["retry-after"], "5");
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("without paying again"));
assert!(!text.contains("private wallet"));
}
}
@@ -0,0 +1,241 @@
use super::{build_response, ApiHandler};
use crate::content_lightning::{Binding, Journal, Phase};
use anyhow::{Context, Result};
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use tokio::io::AsyncReadExt;
pub(crate) const ROUTE: &str = "/content/lightning/v1/operation";
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Operation {
pub binding: Binding,
pub action: String,
}
impl ApiHandler {
pub(super) async fn handle_lightning_purchase(
&self,
mut request: Request<Body>,
) -> Result<Response<Body>> {
anyhow::ensure!(
request.method() == Method::POST && request.uri().path() == ROUTE,
"Invalid invoice route"
);
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
let mut bytes = Vec::new();
while let Some(chunk) = request.body_mut().data().await {
let chunk = chunk?;
anyhow::ensure!(
bytes.len() + chunk.len() <= 16384,
"Invoice request too large"
);
bytes.extend_from_slice(&chunk)
}
Ok::<_, anyhow::Error>(bytes)
})
.await
.context("Invoice request timed out")??;
let seller = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let buyer = crate::content_auth::authenticate_request(
request.headers(),
&seller,
&Method::POST,
ROUTE,
&bytes,
chrono::Utc::now().timestamp(),
)?;
let operation: Operation = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
operation.binding.buyer_did == buyer && operation.binding.seller_did == seller,
"Invoice peer identity mismatch"
);
anyhow::ensure!(
matches!(
operation.action.as_str(),
"create" | "status" | "cancel" | "download"
),
"Invalid invoice action"
);
let binding = &operation.binding;
let journal = Journal::open(&self.config.data_dir).await?;
let mut saved = journal.seller(binding)?;
if saved.is_none() {
anyhow::ensure!(
operation.action == "create",
"Unknown original invoice operation"
);
anyhow::ensure!(
!binding.content_id.starts_with("registered_"),
"Registered rentals use their native purchase contract"
);
let catalog = crate::content_server::load_catalog(&self.config.data_dir).await?;
let item = catalog
.items
.iter()
.find(|v| v.id == binding.content_id)
.context("Shared item unavailable")?;
let visible = match &item.availability {
crate::content_server::Availability::Nobody => false,
crate::content_server::Availability::AllPeers => true,
crate::content_server::Availability::Specific { peers } => peers.contains(&buyer),
};
anyhow::ensure!(visible, "Item is not shared with this buyer");
anyhow::ensure!(
matches!(&item.access,crate::content_server::AccessControl::Paid{price_sats,..} if *price_sats==binding.price_sats)
&& crate::content_server::method_accepted(&item.access, "lightning"),
"Invoice price or accepted method changed"
);
crate::content_server::ensure_payment_source_available(&self.config.data_dir, item)
.await?;
let source = crate::content_server::content_file_path(&self.config.data_dir, item);
let roots = [
self.config.data_dir.join("content/files"),
self.config.data_dir.join("filebrowser"),
];
let (root, relative) = roots
.iter()
.find_map(|root| {
source
.strip_prefix(root)
.ok()
.map(|p| (root.clone(), p.to_path_buf()))
})
.context("Unsupported invoice source root")?;
let data = self.config.data_dir.clone();
let id = binding.content_id.clone();
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelCopy {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)));
let cancelled = cancel_copy.0.clone();
let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::prepare(
&data,
&root,
&id,
&relative,
&crate::media_registration::Limits {
max_bytes: 64 * 1024 * 1024 * 1024,
cancelled: &cancelled,
},
64 * 1024 * 1024 * 1024,
512 * 1024 * 1024,
|_| Ok(()),
)
})
.await??;
anyhow::ensure!(
snapshot.size == item.size_bytes,
"Shared file changed before invoice"
);
// Source metadata is private and committed before AddInvoice dispatch.
let record = crate::content_server::publish_snapshot_invoice(
&self.config.data_dir,
item,
&journal,
binding.clone(),
crate::content_lightning::RetainedFile {
sha256: snapshot.sha256,
size: snapshot.size,
filename: item.filename.clone(),
mime_type: item.mime_type.clone(),
},
)
.await?;
saved = Some(record);
}
let mut saved = saved.context("Missing invoice operation")?;
anyhow::ensure!(
saved.source.is_some(),
"Original invoice source is not prepared; no new invoice dispatched"
);
let status = if operation.action == "cancel" && saved.phase == Phase::Prepared {
saved.phase = Phase::CanceledUnpaid;
journal.save_seller(&saved)?;
saved.status()
} else if operation.action != "create"
&& operation.action != "cancel"
&& saved.phase == Phase::Prepared
{
saved.status()
} else {
self.rpc_handler
.drive_external_invoice(&journal, binding, operation.action == "cancel")
.await?
};
// The original legacy delivery mechanism remains usable by its hash.
if status.bolt11.is_some() {
crate::content_invoice::record_pending(
&self.config.data_dir,
&status.payment_hash,
&binding.content_id,
binding.price_sats,
)
.await?;
if status.state == Phase::Settled {
crate::content_invoice::mark_paid(&self.config.data_dir, &status.payment_hash)
.await?;
}
}
if operation.action == "download" {
anyhow::ensure!(
status.state == Phase::Settled,
"Original invoice has not settled"
);
let source = status
.source
.as_ref()
.context("Original invoice snapshot is missing")?;
let data = self.config.data_dir.clone();
let id = binding.content_id.clone();
let retained = source.clone();
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelCopy {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)));
let cancelled = cancel_copy.0.clone();
let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
})
.await??;
let stream = futures_util::stream::try_unfold(
(tokio::fs::File::from_std(snapshot.file), source.size),
|(mut file, left)| async move {
if left == 0 {
return Ok::<_, std::io::Error>(None);
}
let mut bytes = vec![0; left.min(65536) as usize];
let count = file.read(&mut bytes).await?;
if count == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Original invoice snapshot ended early",
));
}
bytes.truncate(count);
Ok(Some((bytes, (file, left - count as u64))))
},
);
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", &source.mime_type)
.header("Content-Length", source.size)
.header("Cache-Control", "private, no-store")
.body(Body::wrap_stream(stream))?);
}
Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&status)?),
))
}
}
+78 -3
View File
@@ -1,12 +1,18 @@
mod blob; mod blob;
mod cdp; mod cdp;
mod cloud_purchase;
mod content; mod content;
mod dwn; mod dwn;
pub(crate) mod lightning_purchase;
mod model_proxy; mod model_proxy;
mod node_message; mod node_message;
pub(crate) mod onchain_purchase;
mod proxy; mod proxy;
mod purchase;
mod registered_media;
mod remote_input; mod remote_input;
mod remote_relay; mod remote_relay;
mod rental_playback;
mod routstr_proxy; mod routstr_proxy;
mod websocket; mod websocket;
@@ -384,6 +390,12 @@ impl ApiHandler {
let path = req.uri().path().to_string(); let path = req.uri().path().to_string();
let method = req.method().clone(); let method = req.method().clone();
if path.starts_with("/api/rental-playback/") {
return self
.handle_local_rental_request(&method, &path, req.headers())
.await;
}
// Handle CORS preflight for all routes // Handle CORS preflight for all routes
if method == Method::OPTIONS { if method == Method::OPTIONS {
let mut builder = Response::builder() let mut builder = Response::builder()
@@ -444,6 +456,32 @@ impl ApiHandler {
.await; .await;
} }
if method == Method::POST && path == lightning_purchase::ROUTE {
return self.handle_lightning_purchase(req).await;
}
// Purchase routes bound the original body before the generic buffer.
if method == Method::POST
&& matches!(
path.as_str(),
crate::content_purchase_protocol::PREPARE_OFFER_ROUTE
| crate::content_purchase_protocol::OFFER_ROUTE
| crate::content_purchase_protocol::ACCEPT_ROUTE
| crate::content_purchase_protocol::SETTLE_ROUTE
| crate::content_purchase_protocol::STATUS_ROUTE
| crate::content_purchase_protocol::CANCEL_ROUTE
)
{
return self.handle_purchase_request(req).await;
}
if method == Method::POST
&& path.starts_with("/content/registered_")
&& path.contains("/rental/")
&& (path.ends_with("/prepare") || path.ends_with("/start"))
{
return self.handle_rental_control(req).await;
}
// Convert body to bytes for non-WS routes // Convert body to bytes for non-WS routes
let headers = req.headers().clone(); let headers = req.headers().clone();
let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default(); let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default();
@@ -584,6 +622,15 @@ impl ApiHandler {
Self::handle_blob_download(&self.blob_store, p, &query_string).await Self::handle_blob_download(&self.blob_store, p, &query_string).await
} }
// Immutable registered rentals use durable seller receipts and their
// first-open window, never legacy mutable filename shares.
(Method::GET, p) if p.starts_with("/content/") && p.contains("/purchase/") => {
self.handle_cloud_purchase(p, &headers).await
}
(Method::GET, p) if p.starts_with("/content/registered_") && p.contains("/rental/") => {
self.handle_registered_rental(p, &headers).await
}
// Content preview — degraded previews for paid content (no auth, no payment) // Content preview — degraded previews for paid content (no auth, no payment)
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => { (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
Self::handle_content_preview(p, &self.config).await Self::handle_content_preview(p, &self.config).await
@@ -591,7 +638,7 @@ impl ApiHandler {
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement // Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => { (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => {
self.handle_content_invoice(p).await self.handle_content_invoice(p, &headers).await
} }
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => { (Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
self.handle_content_invoice_status(p).await self.handle_content_invoice_status(p).await
@@ -602,7 +649,7 @@ impl ApiHandler {
self.handle_content_onchain_status(p).await self.handle_content_onchain_status(p).await
} }
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => { (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => {
self.handle_content_onchain(p).await self.handle_content_onchain(p, &headers).await
} }
// Content serving — peers access shared content over Tor (no session auth); // Content serving — peers access shared content over Tor (no session auth);
@@ -612,7 +659,7 @@ impl ApiHandler {
} }
// Content catalog — list available content (no session auth, for peers) // Content catalog — list available content (no session auth, for peers)
(Method::GET, "/content") => Self::handle_content_catalog(&self.config).await, (Method::GET, "/content") => self.handle_content_catalog(&headers).await,
// Electrs status — unauthenticated (read-only sync status) // Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await, (Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
@@ -623,6 +670,34 @@ impl ApiHandler {
// (upstream Gitea has no ACAO header) or CSP (IP-port upstream // (upstream Gitea has no ACAO header) or CSP (IP-port upstream
// falls outside `connect-src`). Session-authenticated so only // falls outside `connect-src`). Session-authenticated so only
// the logged-in node owner can spin up fetches. // the logged-in node owner can spin up fetches.
(Method::GET, "/api/node-app-catalog") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let data_dir = self.config.data_dir.clone();
let result = tokio::task::spawn_blocking(move || {
crate::container::node_catalog::verified_body(&data_dir)
})
.await
.unwrap_or_else(|error| Err(anyhow::anyhow!(error)));
let (status, body) = match result {
Ok(Some(body)) => (StatusCode::OK, body),
Ok(None) => (StatusCode::NOT_FOUND, "{}".to_owned()),
Err(error) => {
tracing::warn!("Node demo catalog rejected: {error}");
(
StatusCode::CONFLICT,
"{\"error\":\"Node demo catalog is unavailable\"}".to_owned(),
)
}
};
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "private, no-store")
.body(hyper::Body::from(body))?)
}
(Method::GET, "/api/app-catalog") => { (Method::GET, "/api/app-catalog") => {
if !self.is_authenticated(&headers).await { if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized()); return Ok(Self::unauthorized());
@@ -0,0 +1,712 @@
use super::{build_response, ApiHandler};
use crate::{content_lightning::Binding, content_onchain_seller::Journal};
use anyhow::{Context, Result};
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use tokio::io::AsyncReadExt;
pub(crate) const ROUTE: &str = "/content/onchain/v1/operation";
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Operation {
pub binding: Binding,
pub action: String,
}
// Load wallet credentials only after authenticated request validation reaches a
// wallet operation. Tests inject the same typed boundary without live services.
struct NativeSellerWallet<'a>(&'a crate::api::rpc::RpcHandler);
impl crate::content_onchain_seller::Wallet for NativeSellerWallet<'_> {
async fn network(&self) -> Result<crate::content_onchain::ChainNetwork> {
self.0.onchain_purchase_wallet().await?.network().await
}
async fn preflight(&self, network: crate::content_onchain::ChainNetwork) -> Result<()> {
self.0
.onchain_purchase_wallet()
.await?
.preflight(network)
.await
}
async fn allocate(&self) -> Result<String> {
self.0.onchain_purchase_wallet().await?.allocate().await
}
async fn received(&self, address: &str, amount: u64) -> Result<bool> {
self.0
.onchain_purchase_wallet()
.await?
.received(address, amount)
.await
}
}
impl ApiHandler {
pub(super) async fn handle_onchain_purchase(
&self,
request: Request<Body>,
) -> Result<Response<Body>> {
self.handle_onchain_purchase_with_wallet(request, &NativeSellerWallet(&self.rpc_handler))
.await
}
async fn handle_onchain_purchase_with_wallet<W: crate::content_onchain_seller::Wallet>(
&self,
mut request: Request<Body>,
wallet: &W,
) -> Result<Response<Body>> {
anyhow::ensure!(
request.method() == Method::POST && request.uri().path() == ROUTE,
"Invalid on-chain purchase route"
);
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
let mut bytes = Vec::new();
while let Some(chunk) = request.body_mut().data().await {
let chunk = chunk?;
anyhow::ensure!(
bytes.len() + chunk.len() <= 16384,
"On-chain purchase request too large"
);
bytes.extend_from_slice(&chunk)
}
Ok::<_, anyhow::Error>(bytes)
})
.await
.context("On-chain purchase request timed out")??;
let seller = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let buyer = crate::content_auth::authenticate_request(
request.headers(),
&seller,
&Method::POST,
ROUTE,
&bytes,
chrono::Utc::now().timestamp(),
)?;
let operation: Operation = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
operation.binding.buyer_did == buyer && operation.binding.seller_did == seller,
"On-chain purchase peer identity mismatch"
);
anyhow::ensure!(
matches!(
operation.action.as_str(),
"create" | "offer" | "allocate" | "status" | "download" | "cancel"
),
"Invalid on-chain purchase action"
);
let binding = &operation.binding;
let journal = Journal::open(&self.config.data_dir).await?;
let retired = if operation.action == "cancel" {
Some(journal.retire_unallocated(binding)?)
} else {
journal.retirement(binding)?
};
if let Some(ack) = retired {
return Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&ack)?),
));
}
let mut saved = journal.load(binding)?;
if saved.is_none() {
anyhow::ensure!(
matches!(operation.action.as_str(), "create" | "offer"),
"Unknown original on-chain purchase operation"
);
anyhow::ensure!(
!binding.content_id.starts_with("registered_"),
"Registered rentals use their native purchase contract"
);
let catalog = crate::content_server::load_catalog(&self.config.data_dir).await?;
let item = catalog
.items
.iter()
.find(|v| v.id == binding.content_id)
.context("Shared item unavailable")?;
let visible = match &item.availability {
crate::content_server::Availability::Nobody => false,
crate::content_server::Availability::AllPeers => true,
crate::content_server::Availability::Specific { peers } => peers.contains(&buyer),
};
anyhow::ensure!(visible, "Item is not shared with this buyer");
anyhow::ensure!(
matches!(&item.access,crate::content_server::AccessControl::Paid{price_sats,..} if *price_sats==binding.price_sats)
&& crate::content_server::method_accepted(&item.access, "onchain"),
"On-chain purchase price or accepted method changed"
);
crate::content_server::ensure_payment_source_available(&self.config.data_dir, item)
.await?;
let source = crate::content_server::content_file_path(&self.config.data_dir, item);
let roots = [
self.config.data_dir.join("content/files"),
self.config.data_dir.join("filebrowser"),
];
let (root, relative) = roots
.iter()
.find_map(|root| {
source
.strip_prefix(root)
.ok()
.map(|p| (root.clone(), p.to_path_buf()))
})
.context("Unsupported on-chain purchase source root")?;
let data = self.config.data_dir.clone();
let id = binding.content_id.clone();
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelCopy {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)));
let cancelled = cancel_copy.0.clone();
let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::prepare(
&data,
&root,
&id,
&relative,
&crate::media_registration::Limits {
max_bytes: 64 * 1024 * 1024 * 1024,
cancelled: &cancelled,
},
64 * 1024 * 1024 * 1024,
512 * 1024 * 1024,
|_| Ok(()),
)
})
.await??;
anyhow::ensure!(
snapshot.size == item.size_bytes,
"Shared file changed before on-chain purchase"
);
// Source metadata is private and committed before address allocation.
let record = crate::content_server::publish_snapshot_onchain(
&self.config.data_dir,
item,
&journal,
binding.clone(),
crate::content_lightning::RetainedFile {
sha256: snapshot.sha256,
size: snapshot.size,
filename: item.filename.clone(),
mime_type: item.mime_type.clone(),
},
wallet.network().await?,
)
.await?;
saved = Some(record);
}
saved.context("Missing original on-chain operation")?;
let status = if operation.action == "allocate" {
crate::content_server::allocate_onchain_offer(
&self.config.data_dir,
&journal,
binding,
wallet,
)
.await?
} else {
crate::content_onchain_seller::drive(&journal, binding, false, wallet).await?
};
if operation.action == "download" {
anyhow::ensure!(status.paid, "Original on-chain purchase has not settled");
let source = &status.source;
let data = self.config.data_dir.clone();
let id = binding.content_id.clone();
let retained = source.clone();
let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
})
.await??;
let stream = futures_util::stream::try_unfold(
(tokio::fs::File::from_std(snapshot.file), source.size),
|(mut file, left)| async move {
if left == 0 {
return Ok::<_, std::io::Error>(None);
}
let mut bytes = vec![0; left.min(65536) as usize];
let count = file.read(&mut bytes).await?;
if count == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Original on-chain purchase snapshot ended early",
));
}
bytes.truncate(count);
Ok(Some((bytes, (file, left - count as u64))))
},
);
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", &source.mime_type)
.header("Content-Length", source.size)
.header("Cache-Control", "private, no-store")
.body(Body::wrap_stream(stream))?);
}
Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&status)?),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content_onchain_seller::{Allocation, UnallocatedAck};
use hyper::service::{make_service_fn, service_fn};
use std::{convert::Infallible, sync::Arc};
#[derive(Default)]
struct MockWallet {
allocations: std::sync::atomic::AtomicUsize,
lose_reply: std::sync::atomic::AtomicBool,
}
impl crate::content_onchain_seller::Wallet for MockWallet {
async fn network(&self) -> Result<crate::content_onchain::ChainNetwork> {
Ok(crate::content_onchain::ChainNetwork::Regtest)
}
async fn preflight(&self, _: crate::content_onchain::ChainNetwork) -> Result<()> {
Ok(())
}
async fn allocate(&self) -> Result<String> {
self.allocations
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
anyhow::ensure!(
!self
.lose_reply
.swap(false, std::sync::atomic::Ordering::SeqCst),
"Simulated lost allocation response"
);
let mut bytes = vec![0, 20];
bytes.extend([17u8; 20]);
Ok(bitcoin::Address::from_script(
&bitcoin::ScriptBuf::from_bytes(bytes),
bitcoin::Network::Regtest,
)?
.to_string())
}
async fn received(&self, _: &str, _: u64) -> Result<bool> {
Ok(false)
}
}
struct HttpFixture {
wallet: Arc<MockWallet>,
data: tempfile::TempDir,
_buyer_data: tempfile::TempDir,
buyer: crate::identity::NodeIdentity,
seller: String,
url: String,
task: tokio::task::JoinHandle<()>,
}
impl Drop for HttpFixture {
fn drop(&mut self) {
self.task.abort();
}
}
async fn fixture() -> HttpFixture {
let data = tempfile::tempdir().unwrap();
let buyer_data = tempfile::tempdir().unwrap();
let buyer = crate::identity::NodeIdentity::load_or_create(buyer_data.path())
.await
.unwrap();
let mut config = crate::config::Config::default();
config.data_dir = data.path().to_path_buf();
let handler = Arc::new(
ApiHandler::new(
config,
Arc::new(crate::state::StateManager::new()),
Arc::new(crate::monitoring::MetricsStore::new()),
None,
None,
)
.await
.unwrap(),
);
let seller = crate::identity::did_key_from_pubkey_hex(&handler.self_pubkey_hex).unwrap();
let wallet = Arc::new(MockWallet::default());
let server_wallet = wallet.clone();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let server = hyper::Server::from_tcp(listener)
.unwrap()
.serve(make_service_fn(move |_| {
let handler = handler.clone();
let wallet = server_wallet.clone();
async move {
Ok::<_, Infallible>(service_fn(move |request| {
let handler = handler.clone();
let wallet = wallet.clone();
async move {
Ok::<_, Infallible>(
handler
.handle_onchain_purchase_with_wallet(request, wallet.as_ref())
.await
.unwrap_or_else(|_| {
build_response(
StatusCode::BAD_REQUEST,
"application/json",
Body::from("{\"error\":\"rejected\"}"),
)
}),
)
}
}))
}
}));
let task = tokio::spawn(async move {
server.await.unwrap();
});
HttpFixture {
wallet,
data,
_buyer_data: buyer_data,
buyer,
seller,
url,
task,
}
}
impl HttpFixture {
fn binding(&self) -> Binding {
Binding {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: self.buyer.did_key().unwrap(),
seller_did: self.seller.clone(),
content_id: "file".into(),
price_sats: 546,
}
}
async fn send(
&self,
body: &[u8],
signed_body: Option<&[u8]>,
audience: Option<&str>,
) -> reqwest::Response {
let mut request = reqwest::Client::new()
.post(format!("{}{}", self.url, ROUTE))
.header("content-type", "application/json")
.body(body.to_vec());
if let Some(signed) = signed_body {
let proof = crate::content_auth::sign_request(
&self.buyer,
audience.unwrap_or(&self.seller),
&Method::POST,
ROUTE,
signed,
chrono::Utc::now().timestamp(),
)
.unwrap();
request = request.header(crate::content_auth::REQUEST_HEADER, proof);
}
request.send().await.unwrap()
}
async fn operation(&self, binding: &Binding, action: &str) -> reqwest::Response {
let body = serde_json::to_vec(&Operation {
binding: binding.clone(),
action: action.into(),
})
.unwrap();
self.send(&body, Some(&body), None).await
}
}
#[tokio::test]
async fn authenticated_cancel_roundtrip_lost_reply_and_delayed_create_return_same_retirement() {
let server = fixture().await;
let binding = server.binding();
// Drop the original reply after headers: terminal state must already be durable.
let first = server.operation(&binding, "cancel").await;
assert_eq!(first.status(), reqwest::StatusCode::OK);
drop(first);
let replay = server.operation(&binding, "cancel").await;
assert_eq!(replay.status(), reqwest::StatusCode::OK);
let ack: UnallocatedAck = replay.json().await.unwrap();
ack.validate(&binding).unwrap();
let delayed = server.operation(&binding, "create").await;
assert_eq!(delayed.status(), reqwest::StatusCode::OK);
assert_eq!(delayed.json::<UnallocatedAck>().await.unwrap(), ack);
let journal = Journal::open(server.data.path()).await.unwrap();
assert_eq!(journal.retirement(&binding).unwrap(), Some(ack));
assert!(journal.load(&binding).unwrap().is_none());
assert!(!server.data.path().join("content-snapshots").exists());
}
#[tokio::test]
async fn cancellation_http_rejects_missing_proof_body_tamper_and_wrong_seller_without_tombstone(
) {
let server = fixture().await;
let binding = server.binding();
let body = serde_json::to_vec(&Operation {
binding: binding.clone(),
action: "cancel".into(),
})
.unwrap();
assert!(!server.send(&body, None, None).await.status().is_success());
let mut changed = binding.clone();
changed.price_sats += 1;
let changed = serde_json::to_vec(&Operation {
binding: changed,
action: "cancel".into(),
})
.unwrap();
assert!(!server
.send(&changed, Some(&body), None)
.await
.status()
.is_success());
let wrong = crate::identity::did_key_from_pubkey_hex(&hex::encode([8; 32])).unwrap();
assert!(!server
.send(&body, Some(&body), Some(&wrong))
.await
.status()
.is_success());
let journal = Journal::open(server.data.path()).await.unwrap();
assert!(journal.retirement(&binding).unwrap().is_none());
}
#[tokio::test]
async fn authenticated_cancel_cannot_retire_dispatched_or_issued_address() {
let server = fixture().await;
let mut script = vec![0, 20];
script.extend([1; 20]);
let address = bitcoin::Address::from_script(
&bitcoin::ScriptBuf::from_bytes(script),
bitcoin::Network::Regtest,
)
.unwrap()
.to_string();
for allocation in [Allocation::Dispatched, Allocation::Ready { address }] {
let binding = server.binding();
let journal = Journal::open(server.data.path()).await.unwrap();
let mut record = journal
.prepare(
binding.clone(),
crate::content_lightning::RetainedFile {
sha256: "a".repeat(64),
size: 4,
filename: "original.txt".into(),
mime_type: "text/plain".into(),
},
crate::content_onchain::ChainNetwork::Regtest,
)
.unwrap();
record.allocation = allocation.clone();
journal.save(&record).unwrap();
drop(journal);
assert!(!server
.operation(&binding, "cancel")
.await
.status()
.is_success());
let journal = Journal::open(server.data.path()).await.unwrap();
assert!(journal.retirement(&binding).unwrap().is_none());
assert_eq!(
journal.load(&binding).unwrap().unwrap().allocation,
allocation
);
}
}
async fn seed_unallocated_offer(server: &HttpFixture) -> Binding {
let binding = server.binding();
crate::content_server::save_catalog(
server.data.path(),
&crate::content_server::ContentCatalog {
items: vec![crate::content_server::ContentItem {
id: binding.content_id.clone(),
filename: "original.txt".into(),
mime_type: "text/plain".into(),
size_bytes: 4,
description: String::new(),
added_at: String::new(),
availability: crate::content_server::Availability::AllPeers,
access: crate::content_server::AccessControl::Paid {
price_sats: 546,
accepted: vec!["onchain".into()],
},
}],
},
)
.await
.unwrap();
let root = server.data.path().join("content/files");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("original.txt"), b"test").unwrap();
let cancelled = std::sync::atomic::AtomicBool::new(false);
let snapshot = crate::content_snapshot::prepare(
server.data.path(),
&root,
&binding.content_id,
std::path::Path::new("original.txt"),
&crate::media_registration::Limits {
max_bytes: 1024,
cancelled: &cancelled,
},
1024 * 1024,
0,
|_| Ok(()),
)
.unwrap();
let journal = Journal::open(server.data.path()).await.unwrap();
journal
.prepare(
binding.clone(),
crate::content_lightning::RetainedFile {
sha256: snapshot.sha256,
size: 4,
filename: "original.txt".into(),
mime_type: "text/plain".into(),
},
crate::content_onchain::ChainNetwork::Regtest,
)
.unwrap();
binding
}
#[tokio::test]
async fn authenticated_offer_never_allocates_or_returns_a_receive_address() {
let server = fixture().await;
let binding = seed_unallocated_offer(&server).await;
let result = server.operation(&binding, "offer").await;
assert_eq!(result.status(), reqwest::StatusCode::OK);
let body: serde_json::Value = result.json().await.unwrap();
assert_eq!(body["allocation"]["state"], "prepared");
assert!(body["allocation"].get("address").is_none());
assert!(body.get("address").is_none());
let journal = Journal::open(server.data.path()).await.unwrap();
assert_eq!(
journal.load(&binding).unwrap().unwrap().allocation,
Allocation::Prepared
);
}
#[tokio::test]
async fn reviewed_offer_can_cancel_and_delayed_explicit_allocate_cannot_revive_it() {
let server = fixture().await;
let binding = seed_unallocated_offer(&server).await;
assert_eq!(
server.operation(&binding, "offer").await.status(),
reqwest::StatusCode::OK
);
let retired: UnallocatedAck = server
.operation(&binding, "cancel")
.await
.json()
.await
.unwrap();
retired.validate(&binding).unwrap();
// Represents a delayed Pay request from the old modal after cancellation.
let late = server.operation(&binding, "allocate").await;
assert_eq!(late.status(), reqwest::StatusCode::OK);
assert_eq!(late.json::<UnallocatedAck>().await.unwrap(), retired);
let journal = Journal::open(server.data.path()).await.unwrap();
assert_eq!(
journal.load(&binding).unwrap().unwrap().allocation,
Allocation::Prepared
);
assert_eq!(journal.retirement(&binding).unwrap(), Some(retired));
}
#[tokio::test]
async fn changing_authenticated_offer_body_to_allocate_cannot_dispatch_an_address() {
let server = fixture().await;
let binding = seed_unallocated_offer(&server).await;
let reviewed = serde_json::to_vec(&Operation {
binding: binding.clone(),
action: "offer".into(),
})
.unwrap();
let changed = serde_json::to_vec(&Operation {
binding: binding.clone(),
action: "allocate".into(),
})
.unwrap();
assert!(!server
.send(&changed, Some(&reviewed), None)
.await
.status()
.is_success());
let journal = Journal::open(server.data.path()).await.unwrap();
assert_eq!(
journal.load(&binding).unwrap().unwrap().allocation,
Allocation::Prepared
);
assert!(journal.retirement(&binding).unwrap().is_none());
}
#[tokio::test]
async fn explicit_allocation_reuses_original_address_after_lost_http_reply() {
let server = fixture().await;
let binding = seed_unallocated_offer(&server).await;
assert!(server
.operation(&binding, "offer")
.await
.status()
.is_success());
assert_eq!(
server
.wallet
.allocations
.load(std::sync::atomic::Ordering::SeqCst),
0
);
// Caller loses the response after seller durability; recovery returns the same record.
drop(server.operation(&binding, "allocate").await);
let recovered: crate::content_onchain_seller::Record = server
.operation(&binding, "allocate")
.await
.json()
.await
.unwrap();
assert!(recovered.quote().unwrap().is_some());
let repeated: crate::content_onchain_seller::Record = server
.operation(&binding, "allocate")
.await
.json()
.await
.unwrap();
assert_eq!(recovered, repeated);
assert_eq!(
server
.wallet
.allocations
.load(std::sync::atomic::Ordering::SeqCst),
1
);
assert!(!server
.operation(&binding, "cancel")
.await
.status()
.is_success());
}
#[tokio::test]
async fn lost_wallet_allocation_reply_never_allocates_a_second_address() {
let server = fixture().await;
let binding = seed_unallocated_offer(&server).await;
server
.wallet
.lose_reply
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(!server
.operation(&binding, "allocate")
.await
.status()
.is_success());
let recovered: crate::content_onchain_seller::Record = server
.operation(&binding, "allocate")
.await
.json()
.await
.unwrap();
assert_eq!(recovered.allocation, Allocation::Dispatched);
assert!(recovered.quote().unwrap().is_none());
assert_eq!(
server
.wallet
.allocations
.load(std::sync::atomic::Ordering::SeqCst),
1
);
assert!(!server
.operation(&binding, "cancel")
.await
.status()
.is_success());
}
}
+34 -51
View File
@@ -138,6 +138,19 @@ impl ApiHandler {
cors_origin: &str, cors_origin: &str,
) -> Result<Response<hyper::Body>> { ) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/"); let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
if suffix == "/archy-status" {
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(
rpc.handle_lnd_readiness().await.to_string(),
))?);
}
let url = format!("{LND_REST_BASE_URL}{suffix}"); let url = format!("{LND_REST_BASE_URL}{suffix}");
// LND REST serves a self-signed cert and requires the admin macaroon. // LND REST serves a self-signed cert and requires the admin macaroon.
// A bare reqwest::get() uses the default client, which rejects the // A bare reqwest::get() uses the default client, which rejects the
@@ -225,54 +238,13 @@ impl ApiHandler {
return bad("invalid onion or content id"); return bad("invalid onion or content id");
} }
// Already purchased? Serve the local cache — no network, no // Ownership is checked before opening a bounded file stream. Corrupt
// re-payment. The seller's node charges every fetch by design; the // records or missing purchased bytes never trigger another purchase.
// buyer-side store (content_owned) exists precisely so an owned item match crate::content_owned::open_owned(&self.config.data_dir, onion, content_id).await {
// never has to be bought twice, and the content surface's cards were Ok(Some((mime, file))) => return crate::media_stream::file_response(file, &mime, headers).await,
// hitting the seller's 402 and rendering as permanent placeholders. Ok(None) => {},
// Range is honoured by slicing, so seek/playback works from cache. Err(_) => return Ok(build_response(StatusCode::CONFLICT, "application/json",
if crate::content_owned::is_owned(&self.config.data_dir, onion, content_id).await { hyper::Body::from(serde_json::json!({"error": "Purchased file unavailable locally. Recover the existing purchase without paying again."}).to_string()))),
if let Some((mime_type, bytes)) =
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await
{
let total = bytes.len();
let range = headers
.get("range")
.and_then(|v| v.to_str().ok())
.and_then(crate::content_server::parse_range_header);
if let Some(r) = range {
let start = (r.start as usize).min(total);
let end = r
.end
.map(|e| e as usize)
.unwrap_or(total.saturating_sub(1))
.min(total.saturating_sub(1));
if start <= end && total > 0 {
let slice = &bytes[start..=end];
return Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header("Content-Type", mime_type)
.header("Content-Length", slice.len().to_string())
.header(
"Content-Range",
format!("bytes {}-{}/{}", start, end, total),
)
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(slice.to_vec()))
.unwrap_or_else(|_| Response::new(hyper::Body::empty())));
}
}
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", mime_type)
.header("Content-Length", total.to_string())
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(bytes))
.unwrap_or_else(|_| Response::new(hyper::Body::empty())));
}
// Indexed as owned but bytes missing — fall through to the peer
// rather than erroring: the seller can still serve it (for the
// price already paid, the operator can re-fetch and re-cache).
} }
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
@@ -280,20 +252,31 @@ impl ApiHandler {
// Generous overall timeout: this endpoint serves both seek/Range // Generous overall timeout: this endpoint serves both seek/Range
// playback (small, finishes fast) and full-file downloads of large // playback (small, finishes fast) and full-file downloads of large
// media (#38). 60s was too tight for a multi-hundred-MB transfer over // media (#38). 60s was too tight for a multi-hundred-MB transfer over
// Tor and aborted the download mid-stream. // slow links and aborted the download mid-stream.
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path) let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
.service(crate::settings::transport::PeerService::PeerFiles) .service(crate::settings::transport::PeerService::PeerFiles)
.require_fips()
.record_transport(&self.config.data_dir)
.timeout(std::time::Duration::from_secs(900)); .timeout(std::time::Duration::from_secs(900));
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) { if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
req = req.header("Range", r.to_string()); req = req.header("Range", r.to_string());
} }
let req = req.authenticate_content(&self.config.data_dir).await?;
match req.send_get().await { match req.send_get().await {
Ok((resp, _transport)) => { Ok((resp, transport)) => {
if resp.status().is_redirection() {
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from("{\"error\":\"Peer media redirects are not allowed\"}"),
));
}
let status = resp.status().as_u16(); let status = resp.status().as_u16();
let rh = resp.headers().clone(); let rh = resp.headers().clone();
let mut builder = Response::builder() let mut builder = Response::builder()
.status(status) .status(status)
.header("Accept-Ranges", "bytes"); .header("Accept-Ranges", "bytes")
.header("X-Archipelago-Transport", transport.to_string());
for h in ["content-type", "content-range", "content-length"] { for h in ["content-type", "content-range", "content-length"] {
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) { if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
builder = builder.header(h, v); builder = builder.header(h, v);
@@ -0,0 +1,206 @@
//! Add as api/handler/purchase.rs; dispatch only exact supported POST routes.
use super::{build_response, ApiHandler};
use crate::{
content_purchase::Journal, content_purchase_protocol as protocol, identity::NodeIdentity,
};
use anyhow::{Context, Result};
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OfferRequest {
id: String,
content_id: String,
}
impl ApiHandler {
pub(super) async fn handle_purchase_request(
&self,
mut request: Request<Body>,
) -> Result<Response<Body>> {
let path = request.uri().path().to_owned();
anyhow::ensure!(
request.method() == Method::POST
&& matches!(
path.as_str(),
protocol::PREPARE_OFFER_ROUTE
| protocol::OFFER_ROUTE
| protocol::ACCEPT_ROUTE
| protocol::SETTLE_ROUTE
| protocol::STATUS_ROUTE
| protocol::CANCEL_ROUTE
),
"Unsupported purchase route"
);
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
let mut bytes = Vec::new();
while let Some(chunk) = request.body_mut().data().await {
let chunk = chunk?;
anyhow::ensure!(
bytes
.len()
.checked_add(chunk.len())
.is_some_and(|n| n <= 1024 * 1024),
"Purchase body too large"
);
bytes.extend_from_slice(&chunk);
}
Ok::<_, anyhow::Error>(bytes)
})
.await
.context("Purchase body timed out")??;
let buyer = crate::content_auth::authenticate_request(
request.headers(),
&audience,
&Method::POST,
&path,
&bytes,
chrono::Utc::now().timestamp(),
)?;
let data_dir = &self.config.data_dir;
let result = match path.as_str() {
protocol::PREPARE_OFFER_ROUTE => {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Prepare {
content_id: String,
#[serde(default)]
retry: bool,
expected: Option<crate::content_purchase_caller::ExpectedRental>,
}
let input: Prepare = serde_json::from_slice(&bytes)?;
let identity = std::sync::Arc::new(
NodeIdentity::load_existing(&data_dir.join("identity")).await?,
);
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
let root = data_dir.clone();
serde_json::to_value(
tokio::task::spawn_blocking(move || {
if let Some(expected) = &input.expected {
let (receipt, _) = crate::registered_media::registered_metadata(
&root,
&identity,
&input.content_id,
)?;
expected.verify_metadata(&identity.did_key()?, &receipt)?;
}
crate::registered_media::prepare_registered(
root,
identity,
&input.content_id,
input.retry,
)
})
.await??,
)?
}
protocol::OFFER_ROUTE => {
let body: OfferRequest = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
uuid::Uuid::parse_str(&body.id)?.to_string() == body.id,
"Invalid operation identifier"
);
let saved = {
Journal::open(data_dir)
.await?
.protocol_offer(&body.id)
.await?
};
let offer = if let Some(saved) = saved {
anyhow::ensure!(
saved.buyer_did == buyer && saved.content_id == body.content_id,
"Original offer binding changed"
);
saved
} else {
// Registration pins and immutable snapshot are node-owned;
// no content hash/price/path is accepted from the request.
if !body.content_id.starts_with("registered_") {
let wallet = crate::wallet::ecash::load_wallet(data_dir).await?;
let offer = crate::content_cloud_offer::offer(
data_dir,
&body.id,
&body.content_id,
&buyer,
&audience,
crate::wallet::ecash::load_network(data_dir).await?,
wallet.mint_url.trim_end_matches('/'),
crate::content_cloud_offer::SnapshotPolicy {
max_file_bytes: 64 * 1024 * 1024 * 1024,
max_total_bytes: 64 * 1024 * 1024 * 1024,
minimum_free_bytes: 512 * 1024 * 1024,
},
)
.await?;
return Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&offer)?),
));
}
let identity = NodeIdentity::load_existing(&data_dir.join("identity")).await?;
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
let selected = body.content_id.clone();
let root = data_dir.clone();
let (receipt, terms) = tokio::task::spawn_blocking(move || {
crate::registered_media::registered_terms(&root, &identity, &selected)
})
.await??;
anyhow::ensure!(
receipt
.payment_methods
.iter()
.any(|method| method == "cashu"),
"Content does not accept Cashu"
);
let now = chrono::Utc::now().timestamp();
let deadline = now.checked_add(120).context("Offer clock overflow")?;
let wallet = crate::wallet::ecash::load_wallet(data_dir).await?;
let offer = protocol::Offer {
id: body.id,
buyer_did: buyer.clone(),
seller_did: audience.clone(),
filename: receipt.content_id.clone(),
mime_type: "application/octet-stream".into(),
content_id: receipt.content_id,
content_sha256: receipt.sha256,
content_size: receipt.size_bytes.parse()?,
viewing_seconds: Some(receipt.viewing_seconds),
terms_sha256: terms,
network: crate::wallet::ecash::load_network(data_dir).await?,
mint_url: wallet.mint_url.trim_end_matches('/').to_owned(),
seller_net_sats: receipt.price_sats,
offered_at: now,
expires_at: deadline,
};
protocol::save_offer(data_dir, &offer, &buyer, now).await?
};
protocol::ensure_seller_mint_policy(data_dir, offer.network, &offer.mint_url)
.await?;
serde_json::to_value(offer)?
}
protocol::ACCEPT_ROUTE => serde_json::to_value(
protocol::accept(data_dir, &serde_json::from_slice(&bytes)?, &buyer, || {
chrono::Utc::now().timestamp()
})
.await?,
)?,
protocol::SETTLE_ROUTE => serde_json::to_value(
protocol::settle(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?,
)?,
protocol::CANCEL_ROUTE => serde_json::to_value(
protocol::cancel(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?,
)?,
protocol::STATUS_ROUTE => serde_json::to_value(
protocol::status(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?,
)?,
_ => unreachable!(),
};
Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&result)?),
))
}
}
@@ -0,0 +1,437 @@
//! Authenticated immutable rental streaming, separate from legacy mutable shares.
use super::{build_response, ApiHandler};
use crate::{content_server::ByteRange, identity::NodeIdentity, registered_media::OpenedMedia};
use anyhow::{Context, Result};
use hyper::{Body, HeaderMap, Response, StatusCode};
use std::sync::Arc;
fn route(path: &str) -> Result<(&str, &str)> {
let (content, purchase) = path
.strip_prefix("/content/")
.and_then(|value| value.split_once("/rental/"))
.context("Invalid rental route")?;
anyhow::ensure!(
content.starts_with("registered_") && !content.contains('/') && !purchase.contains('/'),
"Invalid rental identifiers"
);
let id = uuid::Uuid::parse_str(purchase)?;
anyhow::ensure!(
id.to_string() == purchase && id.get_version_num() == 4,
"Invalid purchase identifier"
);
Ok((content, purchase))
}
fn bounds(range: Option<ByteRange>, total: u64) -> Result<Option<(u64, u64)>> {
let Some(range) = range else {
anyhow::ensure!(total > 0, "Registered media is empty");
return Ok(None);
};
let last = total.checked_sub(1).context("Registered media is empty")?;
let (start, end) = match range {
ByteRange::From { start, end } => (start, end.unwrap_or(last).min(last)),
ByteRange::Suffix(count) => {
anyhow::ensure!(count > 0, "Invalid suffix range");
(total.saturating_sub(count), last)
}
};
anyhow::ensure!(start <= end && start < total, "Invalid rental byte range");
Ok(Some((start, end)))
}
fn clock() -> u64 {
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
}
fn denied(message: &'static str) -> Response<Body> {
build_response(StatusCode::FORBIDDEN, "text/plain", Body::from(message))
}
impl ApiHandler {
pub(super) async fn handle_rental_control(
&self,
mut request: hyper::Request<Body>,
) -> Result<Response<Body>> {
use hyper::body::HttpBody;
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Control {
capability: String,
ready_id: Option<String>,
#[serde(default)]
retry: bool,
}
let path = request.uri().path().to_owned();
let (base, action) = path.rsplit_once('/').context("Invalid rental action")?;
anyhow::ensure!(
matches!(action, "prepare" | "start") && request.method() == hyper::Method::POST,
"Invalid rental action"
);
let (content, purchase) = route(base)?;
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
let mut bytes = Vec::new();
while let Some(chunk) = request.body_mut().data().await {
let chunk = chunk?;
anyhow::ensure!(
bytes
.len()
.checked_add(chunk.len())
.is_some_and(|n| n <= 16 * 1024),
"Rental request too large"
);
bytes.extend_from_slice(&chunk);
}
Ok::<_, anyhow::Error>(bytes)
})
.await
.context("Rental request timed out")??;
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let buyer = crate::content_auth::authenticate_request(
request.headers(),
&audience,
&hyper::Method::POST,
&path,
&bytes,
chrono::Utc::now().timestamp(),
)?;
let input: Control = serde_json::from_slice(&bytes)?;
let identity =
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
let result = if action == "prepare" {
anyhow::ensure!(input.ready_id.is_none(), "Prepare does not start a rental");
let prior = crate::registered_media::paid_window(
&self.config.data_dir,
&identity,
content,
purchase,
&buyer,
&input.capability,
)
.await?;
let metadata = crate::registered_media::registered_metadata(
&self.config.data_dir,
&identity,
content,
)?;
anyhow::ensure!(
prior
.as_ref()
.is_none_or(|window| clock() >= window.started_at),
"Rental clock moved backwards"
);
if let Some(window) = prior.as_ref().filter(|window| clock() >= window.expires_at) {
serde_json::json!({"state":"expired", "viewing_seconds":metadata.0.viewing_seconds,"started_at":window.started_at,"expires_at":window.expires_at})
} else {
let state = crate::registered_media::prepare_paid(
self.config.data_dir.clone(),
identity,
content.into(),
purchase.into(),
buyer,
input.capability,
input.retry,
)
.await?;
let mut result = serde_json::to_value(state)?;
result["viewing_seconds"] = serde_json::json!(metadata.0.viewing_seconds);
result["started_at"] =
serde_json::json!(prior.as_ref().map(|window| window.started_at));
result["expires_at"] =
serde_json::json!(prior.as_ref().map(|window| window.expires_at));
result
}
} else {
anyhow::ensure!(!input.retry, "Start cannot retry verification");
let ready_id = input
.ready_id
.context("Media must be ready before explicit Start")?;
let window = crate::registered_media::start_paid(
self.config.data_dir.clone(),
identity,
content.into(),
purchase.into(),
buyer,
input.capability,
ready_id,
)
.await?;
anyhow::ensure!(clock() >= window.started_at, "Rental clock moved backwards");
serde_json::json!({"state": if clock() >= window.expires_at {"expired"} else {"started"},
"started_at":window.started_at,"expires_at":window.expires_at})
};
Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&result)?),
))
}
pub(super) async fn handle_registered_rental(
&self,
path: &str,
headers: &HeaderMap,
) -> Result<Response<Body>> {
let (content, purchase) = match route(path) {
Ok(ids) => ids,
Err(_) => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
Body::from("Invalid rental route"),
))
}
};
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let buyer = match crate::content_auth::incoming(
headers,
&audience,
path,
chrono::Utc::now().timestamp(),
) {
Ok(Some(buyer)) => buyer,
_ => return Ok(denied("Authenticated node proof is required")),
};
let capability = match headers
.get("x-content-capability")
.and_then(|v| v.to_str().ok())
{
Some(value) if value.len() == 64 => value.to_owned(),
_ => return Ok(denied("Original purchase capability is required")),
};
let requested_range = match headers.get("range") {
None => None,
Some(value) => match value
.to_str()
.ok()
.and_then(crate::content_server::parse_range_header)
{
Some(range) => Some(range),
None => {
return Ok(build_response(
StatusCode::RANGE_NOT_SATISFIABLE,
"text/plain",
Body::from("Invalid byte range"),
))
}
},
};
let identity =
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
let data = self.config.data_dir.clone();
let selected = content.to_owned();
let key = identity.clone();
let metadata = tokio::task::spawn_blocking(move || {
crate::registered_media::registered_metadata(&data, &key, &selected)
})
.await?;
let (receipt, _) = match metadata {
Ok(value) => value,
Err(_) => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
Body::from("Registered content is unavailable"),
))
}
};
let total = receipt.size_bytes.parse::<u64>()?;
let range = match bounds(requested_range, total) {
Ok(value) => value,
Err(_) => {
return Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", format!("bytes */{total}"))
.body(Body::from("Invalid byte range"))?)
}
};
// All malformed/out-of-bounds requests are rejected before first-open
// rental creation. No payment or new receipt is attempted by this route.
let opened = match crate::registered_media::open_paid(
self.config.data_dir.clone(),
identity,
content.into(),
purchase.into(),
buyer,
capability,
)
.await
{
Ok(opened) => opened,
Err(_) => {
return Ok(denied(
"This purchase is not settled, does not match, or its rental has expired",
))
}
};
rental_response(opened, range, Arc::new(clock)).await
}
}
async fn rental_response(
opened: OpenedMedia,
range: Option<(u64, u64)>,
now: Arc<dyn Fn() -> u64 + Send + Sync>,
) -> Result<Response<Body>> {
anyhow::ensure!(
opened.still_authorized(now()),
"Rental expired before streaming"
);
let total = opened.size_bytes;
let started = opened.started_at;
let expires = opened.expires_at;
let (start, length) = range.map_or((0, total), |(start, end)| (start, end - start + 1));
let chunks = futures_util::stream::try_unfold(
(opened.file, opened.verification, start, length, now),
move |(mut file, verification, position, left, now)| async move {
if left == 0 {
return Ok::<_, std::io::Error>(None);
}
let instant = now();
if instant < started || instant >= expires {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Rental window ended",
));
}
let read = tokio::task::spawn_blocking(move || {
let bytes = verification
.index
.read_slice(&mut file, position, left.min(64 * 1024) as usize)
.map_err(std::io::Error::other)?;
Ok::<_, std::io::Error>((file, verification, bytes))
});
let (file, verification, bytes) =
tokio::time::timeout(std::time::Duration::from_secs(expires - instant), read)
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "Rental window ended")
})?
.map_err(std::io::Error::other)??;
let instant = now();
if instant < started || instant >= expires {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Rental window ended",
));
}
let count = bytes.len() as u64;
Ok(Some((
bytes,
(file, verification, position + count, left - count, now),
)))
},
);
let mut response = Response::builder()
.status(if range.is_some() {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
})
.header("Content-Type", opened.mime_type)
.header("Content-Length", length)
.header("Accept-Ranges", "bytes")
.header("X-Content-Type-Options", "nosniff")
.header("Cache-Control", "private, no-store")
.header("X-Rental-Expires-At", expires);
if let Some((start, end)) = range {
response = response.header("Content-Range", format!("bytes {start}-{end}/{total}"));
}
Ok(response.body(Body::wrap_stream(chunks))?)
}
#[cfg(test)]
mod tests {
use super::*;
use hyper::body::HttpBody;
use std::sync::atomic::{AtomicU64, Ordering};
#[test]
fn invalid_routes_and_ranges_cannot_reach_rental_creation() {
let id = uuid::Uuid::new_v4();
assert!(route(&format!("/content/registered_{id}/rental/{id}")).is_ok());
for path in [
format!("/content/registered_{id}/rental/{id}/extra"),
format!("/content/../rental/{id}"),
format!("/content/registered_{id}/rental/not-a-purchase"),
] {
assert!(route(&path).is_err());
}
assert!(bounds(
Some(ByteRange::From {
start: 20,
end: None
}),
20
)
.is_err());
assert!(bounds(
Some(ByteRange::From {
start: 9,
end: Some(8)
}),
20
)
.is_err());
assert_eq!(
bounds(Some(ByteRange::Suffix(5)), 20).unwrap(),
Some((15, 19))
);
}
fn opened(size: u64) -> OpenedMedia {
use sha2::{Digest, Sha256};
let mut file = tempfile::tempfile().unwrap();
file.set_len(size).unwrap();
let binding = crate::rental_chunk_index::Binding {
content_id: format!("registered_{}", uuid::Uuid::new_v4()),
receipt_sha256: "ab".repeat(32),
full_sha256: hex::encode(Sha256::digest(vec![0; size as usize])),
size,
};
let index = crate::rental_chunk_index::Index::scan(&mut file, binding, |_| Ok(())).unwrap();
OpenedMedia {
file,
verification: crate::rental_readiness::Ready::fixture(index),
size_bytes: size,
mime_type: "video/mp4".into(),
started_at: 1000,
expires_at: 1060,
}
}
#[tokio::test]
async fn bounded_stream_stops_at_persisted_deadline_without_restarting_window() {
let clock = Arc::new(AtomicU64::new(1000));
let read_clock = clock.clone();
let mut response = rental_response(
opened(200_000),
None,
Arc::new(move || read_clock.load(Ordering::SeqCst)),
)
.await
.unwrap();
assert_eq!(response.headers()["x-rental-expires-at"], "1060");
assert_eq!(
response.body_mut().data().await.unwrap().unwrap().len(),
64 * 1024
);
clock.store(1060, Ordering::SeqCst);
assert!(response.body_mut().data().await.unwrap().is_err());
}
#[tokio::test]
async fn suffix_response_has_exact_length_and_expired_or_rollback_stream_denies() {
let mut response = rental_response(opened(20), Some((15, 19)), Arc::new(|| 1000))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(response.headers()["content-range"], "bytes 15-19/20");
assert_eq!(
hyper::body::to_bytes(response.body_mut())
.await
.unwrap()
.len(),
5
);
assert!(rental_response(opened(20), None, Arc::new(|| 1060))
.await
.is_err());
assert!(rental_response(opened(20), None, Arc::new(|| 999))
.await
.is_err());
}
}
@@ -0,0 +1,548 @@
//! Local browser playback. Only opaque local handles cross the browser boundary.
use super::{build_response, ApiHandler};
use crate::{content_purchase::Journal, content_server::ByteRange, identity::NodeIdentity};
use anyhow::{Context, Result};
use hyper::{Body, HeaderMap, Method, Response, StatusCode};
use std::{
io,
sync::Arc,
time::{Duration, Instant},
};
fn requested_bounds(headers: &HeaderMap, total: u64) -> Result<Option<(u64, u64)>> {
anyhow::ensure!(total > 0, "Empty purchased media");
anyhow::ensure!(
headers.get_all("range").iter().count() <= 1,
"Ambiguous playback ranges"
);
let Some(header) = headers.get("range") else {
return Ok(None);
};
let range = crate::content_server::parse_range_header(header.to_str()?)
.context("Invalid playback byte range")?;
let last = total - 1;
let (start, end) = match range {
ByteRange::From { start, end } => (start, end.unwrap_or(last).min(last)),
ByteRange::Suffix(count) => {
anyhow::ensure!(count > 0, "Invalid byte range");
(total.saturating_sub(count), last)
}
};
anyhow::ensure!(start <= end && start < total, "Invalid playback byte range");
Ok(Some((start, end)))
}
fn validate_upstream(
status: u16,
headers: &HeaderMap,
total: u64,
bounds: Option<(u64, u64)>,
now: u64,
) -> Result<(u16, u64, String, u64)> {
let expected_status = if bounds.is_some() { 206 } else { 200 };
anyhow::ensure!(
status == expected_status,
"Seller returned another range status"
);
let length = bounds.map_or(total, |(start, end)| end - start + 1);
anyhow::ensure!(
headers
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
== Some(length),
"Seller changed purchased byte length"
);
if let Some((start, end)) = bounds {
let expected = format!("bytes {start}-{end}/{total}");
anyhow::ensure!(
headers.get("content-range").and_then(|v| v.to_str().ok()) == Some(expected.as_str()),
"Seller changed purchased byte range"
);
}
let mime = headers
.get("content-type")
.context("Missing media type")?
.to_str()?
.to_owned();
anyhow::ensure!(
mime.starts_with("video/") || mime.starts_with("audio/"),
"Unsupported rental media type"
);
let expires = headers
.get("x-rental-expires-at")
.context("Missing rental expiry")?
.to_str()?
.parse::<u64>()?;
anyhow::ensure!(expires > now, "Rental viewing window ended");
Ok((expected_status, length, mime, expires))
}
fn installed_playback_origin(
origin: &str,
expected: &str,
host: &str,
gated_tls_port: bool,
) -> bool {
let (Ok(actual), Ok(expected), Ok(request)) = (
reqwest::Url::parse(origin),
reqwest::Url::parse(expected),
reqwest::Url::parse(&format!("http://{host}")),
) else {
return false;
};
if !matches!(actual.scheme(), "http" | "https")
|| actual.origin().ascii_serialization() != origin
|| request.path() != "/"
|| !request.username().is_empty()
|| request.password().is_some()
|| request.query().is_some()
|| request.fragment().is_some()
{
return false;
}
if actual.origin() == expected.origin() {
return true;
}
let same_port = actual.port_or_known_default() == expected.port_or_known_default();
let scheme = actual.scheme() == expected.scheme()
|| (gated_tls_port && actual.scheme() == "https" && expected.scheme() == "http");
let expected_loopback = matches!(
expected.host_str(),
Some("localhost" | "127.0.0.1" | "[::1]")
);
same_port
&& scheme
&& actual.host_str() == request.host_str()
&& (expected_loopback || actual.host_str() == expected.host_str())
}
fn unix_now() -> Result<u64> {
Ok(std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs())
}
fn stream_error(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, message)
}
impl ApiHandler {
pub(super) async fn handle_local_rental_request(
&self,
method: &Method,
path: &str,
headers: &HeaderMap,
) -> Result<Response<Body>> {
// HEAD is deliberately not GET: a browser probe must never open a lease.
if method != Method::GET && method != Method::OPTIONS {
return Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET, OPTIONS")
.header("Cache-Control", "no-store")
.body(Body::empty())?);
}
let origin = headers.get("origin").map(|v| v.to_str()).transpose()?;
if let Some(origin) = origin {
let identity =
NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?;
let (state, _) = self.state_manager.get_snapshot().await;
let root = self.config.data_dir.clone();
let context = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&root, &identity, &state)
})
.await??;
let host = headers
.get("host")
.and_then(|value| value.to_str().ok())
.unwrap_or("");
let port = reqwest::Url::parse(origin)
.ok()
.and_then(|url| url.port_or_known_default());
let ports = self.rpc_handler.app_gate.port_map().await;
let gated_tls_port = port
.and_then(|port| ports.gated(port))
.is_some_and(|gate| gate.app_id == context.app_id && gate.declared);
if !context
.app_origins
.iter()
.any(|allowed| installed_playback_origin(origin, allowed, host, gated_tls_port))
{
return Ok(build_response(
StatusCode::FORBIDDEN,
"text/plain",
Body::from("Playback origin is not the installed app"),
));
}
}
let mut response = if method == Method::OPTIONS {
Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())?
} else {
self.handle_local_rental(path, headers).await?
};
response
.headers_mut()
.insert("Cache-Control", "private, no-store".parse()?);
response.headers_mut().insert("Vary", "Origin".parse()?);
if let Some(origin) = origin {
response
.headers_mut()
.insert("Access-Control-Allow-Origin", origin.parse()?);
response
.headers_mut()
.insert("Access-Control-Allow-Credentials", "true".parse()?);
response
.headers_mut()
.insert("Access-Control-Allow-Methods", "GET, OPTIONS".parse()?);
response
.headers_mut()
.insert("Access-Control-Allow-Headers", "Range".parse()?);
response.headers_mut().insert(
"Access-Control-Expose-Headers",
"Content-Length, Content-Range, Accept-Ranges, X-Rental-Expires-At".parse()?,
);
}
Ok(response)
}
/// Dispatcher accepts GET only after normal session handling. HEAD and other
/// methods never reach upstream, so metadata probes cannot start a lease.
pub(super) async fn handle_local_rental(
&self,
path: &str,
headers: &HeaderMap,
) -> Result<Response<Body>> {
let token = match crate::session::extract_session_cookie(headers) {
Some(token) if self.session_store.validate(&token).await => token,
_ => return Ok(Self::unauthorized()),
};
let handle = path
.strip_prefix("/api/rental-playback/")
.context("Invalid playback route")?;
let identity =
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
let (state, _) = self.state_manager.get_snapshot().await;
let root = self.config.data_dir.clone();
let key = identity.clone();
let context = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&root, &key, &state)
})
.await??;
let binding = self
.rpc_handler
.playback_handles()
.lookup(handle, &token, &context)?;
let capability = {
let journal = Journal::open(&self.config.data_dir).await?;
let record = journal
.buyer(&binding.contract.id)
.await?
.context("Original purchase is missing")?;
anyhow::ensure!(
record.contract == binding.contract,
"Original purchase changed"
);
record
.receipt()
.context("Original purchase is not settled")?
.capability
.clone()
};
let peer = crate::federation::load_unique_payment_peer(
&self.config.data_dir,
&binding.seller_onion,
)
.await?;
anyhow::ensure!(
peer.did == binding.contract.seller_did,
"Purchased seller identity changed"
);
let mesh = peer
.fips_npub
.context("Seller mesh binding is unavailable")?;
let total = binding.contract.content_size;
let bounds = match requested_bounds(headers, total) {
Ok(bounds) => bounds,
Err(_) => {
return Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", format!("bytes */{total}"))
.body(Body::empty())?)
}
};
let remote_path = format!(
"/content/{}/rental/{}",
binding.contract.content_id, binding.contract.id
);
let mut request =
crate::fips::dial::PeerRequest::new(Some(&mesh), &binding.seller_onion, &remote_path)
.require_fips()
.single_delivery()
.timeout(Duration::from_secs(24 * 60 * 60))
.header("X-Content-Capability", capability);
if let Some((start, end)) = bounds {
request = request.header("Range", format!("bytes={start}-{end}"));
}
let (response, transport) = tokio::time::timeout(
Duration::from_secs(20),
request.send_content_get(&self.config.data_dir),
)
.await
.context("Seller did not begin the original rental stream")??;
if !response.status().is_success() {
// Never forward arbitrary upstream bodies, redirects, cookies or private headers.
let status = if response.status().as_u16() == 403 {
StatusCode::FORBIDDEN
} else {
StatusCode::BAD_GATEWAY
};
return Ok(build_response(
status,
"text/plain",
Body::from(
"Original rental is unavailable; recover this purchase without paying again",
),
));
}
let (expected_status, length, mime, expires) = validate_upstream(
response.status().as_u16(),
response.headers(),
total,
bounds,
unix_now()?,
)?;
self.rpc_handler
.playback_handles()
.note_expiry(handle, &binding, expires)?;
let sessions = self.session_store.clone();
let state_manager = self.state_manager.clone();
let data_dir = self.config.data_dir.clone();
let chunks = futures_util::stream::try_unfold(
(response, length, None::<Instant>),
move |(mut response, left, mut checked)| {
let sessions = sessions.clone();
let token = token.clone();
let state_manager = state_manager.clone();
let data_dir = data_dir.clone();
let identity = identity.clone();
let context = context.clone();
async move {
if unix_now().map_err(|_| stream_error("Playback clock unavailable"))?
>= expires
{
return Err(stream_error("Rental viewing window ended"));
}
if left == 0 {
return Ok::<_, io::Error>(None);
}
let waiting_since = Instant::now();
loop {
if waiting_since.elapsed() >= Duration::from_secs(30) {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"Rental stream stalled; reopen the original purchase",
));
}
if unix_now().map_err(|_| stream_error("Playback clock unavailable"))?
>= expires
{
return Err(stream_error("Rental viewing window ended"));
}
if checked.is_none_or(|at| at.elapsed() >= Duration::from_secs(1)) {
if !sessions.validate(&token).await {
return Err(stream_error("Playback session ended"));
}
let (state, _) = state_manager.get_snapshot().await;
let root = data_dir.clone();
let key = identity.clone();
let actual = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(
&root, &key, &state,
)
})
.await
.map_err(|_| stream_error("Playback app context unavailable"))?
.map_err(|_| stream_error("Playback app context unavailable"))?;
if actual != context {
return Err(stream_error("Playback app context changed"));
}
checked = Some(Instant::now());
}
// Keep checking revocation while the peer stalls; no local media cache.
let chunk = tokio::select! {
chunk=response.chunk() => chunk.map_err(|_|io::Error::new(io::ErrorKind::ConnectionAborted,"Rental stream interrupted; reopen the original purchase"))?,
_=tokio::time::sleep(Duration::from_secs(1)) => continue,
};
let bytes = chunk.ok_or_else(|| {
io::Error::new(
io::ErrorKind::UnexpectedEof,
"Purchased media ended early",
)
})?;
if bytes.len() as u64 > left {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Purchased media exceeded its declared length",
));
}
let remaining = left - bytes.len() as u64;
return Ok(Some((bytes, (response, remaining, checked))));
}
}
},
);
let mut result = Response::builder()
.status(expected_status)
.header("Content-Type", mime)
.header("Content-Length", length)
.header("Accept-Ranges", "bytes")
.header("Cache-Control", "private, no-store")
.header("X-Content-Type-Options", "nosniff")
.header("X-Rental-Expires-At", expires)
.header("X-Archipelago-Transport", transport.to_string());
if let Some((start, end)) = bounds {
result = result.header("Content-Range", format!("bytes {start}-{end}/{total}"));
}
Ok(result.body(Body::wrap_stream(chunks))?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installed_origin_maps_only_current_host_and_verified_app_port() {
assert!(installed_playback_origin(
"http://192.168.1.5:7778",
"http://127.0.0.1:7778",
"192.168.1.5",
false
));
assert!(installed_playback_origin(
"https://192.168.1.5:7778",
"http://127.0.0.1:7778",
"192.168.1.5",
true
));
assert!(installed_playback_origin(
"https://[fd00::5]:7778",
"https://[::1]:7778",
"[fd00::5]:443",
false
));
for (actual, host, tls) in [
("https://192.168.1.5:7778", "192.168.1.5", false),
("http://evil.test:7778", "192.168.1.5", true),
("http://192.168.1.5:7779", "192.168.1.5", true),
("http://192.168.1.5:7778", "evil.test", true),
("http://192.168.1.5:7778/path", "192.168.1.5", true),
("http://192.168.1.5:7778", "user@192.168.1.5", true),
] {
assert!(
!installed_playback_origin(actual, "http://127.0.0.1:7778", host, tls),
"{actual} {host}"
);
}
}
#[test]
fn range_bounds_follow_purchased_size_and_reject_ambiguous_ranges() {
let check = |range: &str| {
let mut h = HeaderMap::new();
h.insert("range", range.parse().unwrap());
requested_bounds(&h, 100)
};
assert_eq!(check("bytes=20-39").unwrap(), Some((20, 39)));
assert_eq!(check("bytes=90-").unwrap(), Some((90, 99)));
assert_eq!(check("bytes=-10").unwrap(), Some((90, 99)));
assert_eq!(check("bytes=-200").unwrap(), Some((0, 99)));
assert_eq!(check("bytes=90-500").unwrap(), Some((90, 99)));
for range in [
"bytes=100-",
"bytes=20-10",
"bytes=-0",
"bytes=0-1,4-6",
"other=0-1",
] {
assert!(check(range).is_err(), "{range}");
}
assert_eq!(requested_bounds(&HeaderMap::new(), 100).unwrap(), None);
assert!(requested_bounds(&HeaderMap::new(), 0).is_err());
}
#[test]
fn upstream_range_expiry_and_media_headers_are_bound_before_bytes_escape() {
let mut headers = HeaderMap::new();
for (name, value) in [
("content-length", "20"),
("content-range", "bytes 20-39/100"),
("content-type", "video/mp4"),
("x-rental-expires-at", "200"),
] {
headers.insert(name, value.parse().unwrap());
}
assert_eq!(
validate_upstream(206, &headers, 100, Some((20, 39)), 100).unwrap(),
(206, 20, "video/mp4".into(), 200)
);
assert!(validate_upstream(200, &headers, 100, Some((20, 39)), 100).is_err());
assert!(validate_upstream(206, &headers, 100, Some((20, 39)), 200).is_err());
for (name, bad) in [
("content-length", "21"),
("content-range", "bytes 21-40/100"),
("content-type", "text/html"),
("x-rental-expires-at", "0"),
] {
let mut changed = headers.clone();
changed.insert(name, bad.parse().unwrap());
assert!(
validate_upstream(206, &changed, 100, Some((20, 39)), 100).is_err(),
"{name}"
);
}
headers.remove("content-range");
headers.insert("content-length", "100".parse().unwrap());
assert!(validate_upstream(200, &headers, 100, None, 100).is_ok());
}
#[tokio::test]
async fn metadata_probes_and_unauthenticated_get_do_not_touch_purchase_or_identity() {
let root = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = root.path().to_path_buf();
let handler = ApiHandler::new(
config,
Arc::new(crate::state::StateManager::new()),
Arc::new(crate::monitoring::MetricsStore::new()),
None,
None,
)
.await
.unwrap();
// ApiHandler initialization may establish its own node identity, but the
// denied route must not need installed apps, saved receipts or any peer.
for method in [Method::HEAD, Method::POST] {
let result = handler
.handle_request(
hyper::Request::builder()
.method(method)
.uri("/api/rental-playback/invalid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(result.status(), StatusCode::METHOD_NOT_ALLOWED);
}
let result = handler
.handle_request(
hyper::Request::builder()
.uri("/api/rental-playback/invalid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(result.status(), StatusCode::UNAUTHORIZED);
assert!(!root.path().join("content-purchases").exists());
}
}
+277 -15
View File
@@ -5,10 +5,47 @@
use super::RpcHandler; use super::RpcHandler;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::collections::HashSet;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
const ANALYTICS_FILE: &str = "analytics-config.json"; const ANALYTICS_FILE: &str = "analytics-config.json";
/// Collector reports are unsigned claims, including historical on-disk reports.
/// Provenance is assigned here, never accepted from their JSON payload.
fn collector_report(
mut report: serde_json::Value,
expected_id: Option<&str>,
) -> Result<serde_json::Value> {
let id = report
.get("node_id")
.and_then(|v| v.as_str())
.context("Missing collector identity")?;
anyhow::ensure!(
!id.is_empty()
&& id.len() <= 64
&& !id.contains('/')
&& !id.contains('\\')
&& !id.contains("..")
&& !id.ends_with("-history")
&& !id.chars().any(char::is_control),
"Invalid collector identity"
);
anyhow::ensure!(
expected_id.is_none_or(|expected| expected == id),
"Collector identity differs from record"
);
let object = report.as_object_mut().context("Invalid collector report")?;
object.insert("source".into(), serde_json::json!("collector"));
object.insert("trust_level".into(), serde_json::json!("unverified"));
object.insert("identity_authenticated".into(), serde_json::json!(false));
Ok(report)
}
async fn federation_ids(data_dir: &std::path::Path) -> Result<HashSet<String>> {
// A damaged authoritative store must not promote unsigned collector data.
crate::federation::load_node_identities(data_dir).await
}
impl RpcHandler { impl RpcHandler {
/// Check if analytics are enabled. /// Check if analytics are enabled.
pub(super) async fn handle_analytics_get_status(&self) -> Result<serde_json::Value> { pub(super) async fn handle_analytics_get_status(&self) -> Result<serde_json::Value> {
@@ -262,7 +299,7 @@ impl RpcHandler {
&self, &self,
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
let report = params.context("Missing telemetry report payload")?; let report = collector_report(params.context("Missing telemetry report payload")?, None)?;
// Validate required fields // Validate required fields
let node_id = report let node_id = report
@@ -285,6 +322,13 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.context("Missing required field: reported_at")?; .context("Missing required field: reported_at")?;
anyhow::ensure!(
!federation_ids(&self.config.data_dir)
.await?
.contains(node_id),
"Unsigned collector report conflicts with a known node identity"
);
let fleet_dir = self.config.data_dir.join("telemetry-fleet"); let fleet_dir = self.config.data_dir.join("telemetry-fleet");
tokio::fs::create_dir_all(&fleet_dir) tokio::fs::create_dir_all(&fleet_dir)
.await .await
@@ -339,9 +383,9 @@ impl RpcHandler {
let mut nodes: Vec<serde_json::Value> = Vec::new(); let mut nodes: Vec<serde_json::Value> = Vec::new();
// ── Trusted federation nodes ───────────────────────────────────── // ── Trusted federation nodes ─────────────────────────────────────
let fed_nodes = crate::federation::load_nodes(&self.config.data_dir) let fed_nodes = crate::federation::load_nodes(&self.config.data_dir).await?;
.await let mut authoritative = federation_ids(&self.config.data_dir).await?;
.unwrap_or_default(); authoritative.extend(fed_nodes.iter().map(|n| n.did.clone()));
for n in fed_nodes for n in fed_nodes
.iter() .iter()
.filter(|n| n.trust_level == crate::federation::TrustLevel::Trusted) .filter(|n| n.trust_level == crate::federation::TrustLevel::Trusted)
@@ -352,20 +396,19 @@ impl RpcHandler {
(Some(u), Some(t)) if t > 0 => { (Some(u), Some(t)) if t > 0 => {
serde_json::json!((u as f64 / t as f64 * 100.0).round()) serde_json::json!((u as f64 / t as f64 * 100.0).round())
} }
_ => serde_json::json!(0), _ => serde_json::Value::Null,
} }
}; };
let apps = state.map(|s| s.apps.as_slice()).unwrap_or(&[]); let apps = state.map(|s| s.apps.as_slice()).unwrap_or(&[]);
let reported_at = state let reported_at = state
.map(|s| s.timestamp.clone()) .map(|s| s.timestamp.clone())
.or_else(|| n.last_seen.clone()) .or_else(|| n.last_seen.clone());
.unwrap_or_else(|| n.added_at.clone());
let mut report = serde_json::json!({ let mut report = serde_json::json!({
"node_id": n.did, "node_id": n.did,
"node_name": state.and_then(|s| s.node_name.clone()).or_else(|| n.name.clone()), "node_name": state.and_then(|s| s.node_name.clone()).or_else(|| n.name.clone()),
"uptime_secs": state.and_then(|s| s.uptime_secs).unwrap_or(0), "uptime_secs": state.and_then(|s| s.uptime_secs),
"cpu_pct": state.and_then(|s| s.cpu_usage_percent).map(|v| v.round()).unwrap_or(0.0), "cpu_pct": state.and_then(|s| s.cpu_usage_percent).filter(|v| v.is_finite() && (0.0..=100.0).contains(v)).map(|v| v.round()),
"mem_pct": pct(state.and_then(|s| s.mem_used_bytes), state.and_then(|s| s.mem_total_bytes)), "mem_pct": pct(state.and_then(|s| s.mem_used_bytes), state.and_then(|s| s.mem_total_bytes)),
"disk_pct": pct(state.and_then(|s| s.disk_used_bytes), state.and_then(|s| s.disk_total_bytes)), "disk_pct": pct(state.and_then(|s| s.disk_used_bytes), state.and_then(|s| s.disk_total_bytes)),
"container_count": apps.len(), "container_count": apps.len(),
@@ -379,6 +422,7 @@ impl RpcHandler {
"reported_at": reported_at, "reported_at": reported_at,
"trust_level": n.trust_level.to_string(), "trust_level": n.trust_level.to_string(),
"source": "federation", "source": "federation",
"identity_authenticated": true,
}); });
annotate_fleet_report(&mut report); annotate_fleet_report(&mut report);
nodes.push(report); nodes.push(report);
@@ -401,9 +445,20 @@ impl RpcHandler {
match tokio::fs::read_to_string(entry.path()).await { match tokio::fs::read_to_string(entry.path()).await {
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) { Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
Ok(mut report) => { Ok(report) => {
annotate_fleet_report(&mut report); if let Ok(mut report) =
nodes.push(report); collector_report(report, name.strip_suffix(".json"))
{
let id = report["node_id"]
.as_str()
.expect("validated collector identity");
// Include every relationship in this exclusion, so an unsigned
// claim cannot undo observer/untrusted Fleet exclusions either.
if !authoritative.contains(id) {
annotate_fleet_report(&mut report);
nodes.push(report);
}
}
} }
Err(e) => { Err(e) => {
warn!(file = %name, error = %e, "Skipping corrupt fleet report"); warn!(file = %name, error = %e, "Skipping corrupt fleet report");
@@ -450,6 +505,14 @@ impl RpcHandler {
anyhow::bail!("Invalid node_id"); anyhow::bail!("Invalid node_id");
} }
if federation_ids(&self.config.data_dir)
.await?
.contains(node_id)
{
return Ok(serde_json::json!({"node_id":node_id,"entries":[],"count":0,
"history_available":false,"reason":"collector_history_not_authoritative"}));
}
let history_path = self let history_path = self
.config .config
.data_dir .data_dir
@@ -461,8 +524,15 @@ impl RpcHandler {
Err(_) => Vec::new(), Err(_) => Vec::new(),
}; };
let history: Vec<_> = history
.into_iter()
.filter_map(|report| collector_report(report, Some(node_id)).ok())
.collect();
Ok(serde_json::json!({ Ok(serde_json::json!({
"node_id": node_id, "node_id": node_id,
"history_available": true,
"source": "collector",
"identity_authenticated": false,
"entries": history, "entries": history,
"count": history.len(), "count": history.len(),
})) }))
@@ -476,6 +546,7 @@ impl RpcHandler {
return Ok(serde_json::json!({ "alerts": [] })); return Ok(serde_json::json!({ "alerts": [] }));
} }
let authoritative = federation_ids(&self.config.data_dir).await?;
let mut all_alerts: Vec<serde_json::Value> = Vec::new(); let mut all_alerts: Vec<serde_json::Value> = Vec::new();
let mut entries = tokio::fs::read_dir(&fleet_dir) let mut entries = tokio::fs::read_dir(&fleet_dir)
.await .await
@@ -498,19 +569,30 @@ impl RpcHandler {
Err(_) => continue, Err(_) => continue,
}; };
let report = match collector_report(report, name.strip_suffix(".json")) {
Ok(report) => report,
Err(_) => continue,
};
let node_id = report let node_id = report
.get("node_id") .get("node_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
if authoritative.contains(&node_id) {
continue;
}
if let Some(alerts) = report.get("recent_alerts").and_then(|v| v.as_array()) { if let Some(alerts) = report.get("recent_alerts").and_then(|v| v.as_array()) {
for alert in alerts { for alert in alerts {
let mut enriched = alert.clone(); let mut enriched = alert.clone();
if let Some(obj) = enriched.as_object_mut() { if let Some(obj) = enriched.as_object_mut() {
obj.insert("node_id".to_string(), serde_json::json!(node_id)); obj.insert("node_id".to_string(), serde_json::json!(node_id));
obj.insert("source".into(), serde_json::json!("collector"));
obj.insert("trust_level".into(), serde_json::json!("unverified"));
obj.insert("identity_authenticated".into(), serde_json::json!(false));
all_alerts.push(enriched);
} }
all_alerts.push(enriched);
} }
} }
} }
@@ -561,7 +643,7 @@ fn annotate_fleet_report(report: &mut serde_json::Value) {
let is_online = reported let is_online = reported
.map(|dt| { .map(|dt| {
let age = chrono::Utc::now().signed_duration_since(dt); let age = chrono::Utc::now().signed_duration_since(dt);
age.num_minutes() < 30 age.num_seconds() >= -60 && age.num_seconds() < 1800
}) })
.unwrap_or(false); .unwrap_or(false);
@@ -569,7 +651,9 @@ fn annotate_fleet_report(report: &mut serde_json::Value) {
.map(|dt| { .map(|dt| {
let age = chrono::Utc::now().signed_duration_since(dt); let age = chrono::Utc::now().signed_duration_since(dt);
let mins = age.num_minutes(); let mins = age.num_minutes();
if mins < 1 { if age.num_seconds() < -60 {
"unknown (clock ahead)".to_string()
} else if mins < 1 {
"just now".to_string() "just now".to_string()
} else if mins < 60 { } else if mins < 60 {
format!("{}m ago", mins) format!("{}m ago", mins)
@@ -586,3 +670,181 @@ fn annotate_fleet_report(report: &mut serde_json::Value) {
obj.insert("last_seen".to_string(), serde_json::json!(last_seen)); obj.insert("last_seen".to_string(), serde_json::json!(last_seen));
} }
} }
#[cfg(test)]
mod collector_provenance_tests {
use super::*;
use serde_json::json;
#[test]
fn unsigned_and_legacy_reports_cannot_assign_their_own_trust() {
let report = collector_report(
json!({"node_id":"claimed-node", "source":"federation",
"trust_level":"trusted", "identity_authenticated":true, "cpu_pct":0}),
Some("claimed-node"),
)
.unwrap();
assert_eq!(report["source"], "collector");
assert_eq!(report["trust_level"], "unverified");
assert_eq!(report["identity_authenticated"], false);
assert_eq!(report["cpu_pct"], 0);
assert_eq!(
collector_report(report.clone(), Some("claimed-node")).unwrap(),
report
);
}
#[test]
fn collector_cannot_claim_another_record_identity_or_malformed_id() {
for value in [
json!(null),
json!([]),
json!({"node_id":"other"}),
json!({"node_id":"../escape"}),
json!({"node_id":"bad\nidentity"}),
json!({"node_id":"claimed-node-history"}),
] {
assert!(collector_report(value, Some("claimed-node")).is_err());
}
}
#[test]
fn collector_history_suffix_cannot_overwrite_another_nodes_history() {
assert!(collector_report(json!({"node_id":"node-history"}), Some("node-history")).is_err());
assert!(collector_report(json!({"node_id":"node-history"}), None).is_err());
}
#[tokio::test]
async fn fleet_reads_do_not_promote_old_collector_spoofs_or_history() {
let data = tempfile::tempdir().unwrap();
let peer = serde_json::from_value(json!({"did":"known-node", "pubkey":"00".repeat(32),
"onion":format!("{}.onion", "a".repeat(56)), "trust_level":"trusted", "added_at":"now"})).unwrap();
let observer =
serde_json::from_value(json!({"did":"observer-node", "pubkey":"11".repeat(32),
"onion":format!("{}.onion", "a".repeat(56)), "trust_level":"observer", "added_at":"now"}))
.unwrap();
crate::federation::save_nodes(data.path(), &[peer, observer])
.await
.unwrap();
let mut config = crate::config::Config::default();
config.data_dir = data.path().to_path_buf();
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let root = data.path().join("telemetry-fleet");
tokio::fs::create_dir_all(&root).await.unwrap();
let spoof = json!({"node_id":"known-node", "source":"federation", "trust_level":"trusted",
"identity_authenticated":true, "version":"spoof", "reported_at":"2026-10-07T00:00:00Z",
"recent_alerts":[{"message":"spoofed alert", "source":"federation", "identity_authenticated":true}]});
tokio::fs::write(root.join("known-node.json"), spoof.to_string())
.await
.unwrap();
tokio::fs::write(
root.join("known-node-history.json"),
json!([spoof.clone()]).to_string(),
)
.await
.unwrap();
assert!(handler
.handle_telemetry_ingest(Some(spoof.clone()))
.await
.is_err());
let status = handler.handle_telemetry_fleet_status().await.unwrap();
let nodes = status["nodes"].as_array().unwrap();
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0]["source"], "federation");
assert_eq!(nodes[0]["identity_authenticated"], true);
assert_ne!(nodes[0]["version"], "spoof");
let history = handler
.handle_telemetry_fleet_node_history(Some(json!({"node_id":"known-node"})))
.await
.unwrap();
assert_eq!(history["history_available"], false);
assert_eq!(history["count"], 0);
assert!(
handler.handle_telemetry_fleet_alerts().await.unwrap()["alerts"]
.as_array()
.unwrap()
.is_empty()
);
// Display dedup collapses the shared onion, but unsigned reports must
// still be excluded for BOTH raw identities, including the observer.
let ids = federation_ids(data.path()).await.unwrap();
assert!(ids.contains("known-node") && ids.contains("observer-node"));
let mut observer_spoof = spoof.clone();
observer_spoof["node_id"] = json!("observer-node");
tokio::fs::write(root.join("observer-node.json"), observer_spoof.to_string())
.await
.unwrap();
assert!(handler
.handle_telemetry_ingest(Some(observer_spoof))
.await
.is_err());
let status = handler.handle_telemetry_fleet_status().await.unwrap();
assert!(status["nodes"]
.as_array()
.unwrap()
.iter()
.all(|node| node["source"] == "federation"));
assert!(
handler.handle_telemetry_fleet_alerts().await.unwrap()["alerts"]
.as_array()
.unwrap()
.is_empty()
);
let observer_history = handler
.handle_telemetry_fleet_node_history(Some(json!({"node_id":"observer-node"})))
.await
.unwrap();
assert_eq!(observer_history["history_available"], false);
let mut independent = spoof;
independent["node_id"] = json!("independent");
handler
.handle_telemetry_ingest(Some(independent))
.await
.unwrap();
let status = handler.handle_telemetry_fleet_status().await.unwrap();
let collector = status["nodes"]
.as_array()
.unwrap()
.iter()
.find(|v| v["node_id"] == "independent")
.unwrap();
assert_eq!(collector["source"], "collector");
assert_eq!(collector["identity_authenticated"], false);
let alerts = handler.handle_telemetry_fleet_alerts().await.unwrap();
assert_eq!(alerts["alerts"][0]["source"], "collector");
assert_eq!(alerts["alerts"][0]["identity_authenticated"], false);
// Corrupt authoritative state must fail closed, not turn collector
// claims into the fallback representation of known relationships.
let nodes_path = data.path().join("federation").join("nodes.json");
tokio::fs::write(&nodes_path, b"{broken-authoritative-store")
.await
.unwrap();
assert!(federation_ids(data.path()).await.is_err());
assert!(handler.handle_telemetry_fleet_status().await.is_err());
assert!(handler.handle_telemetry_fleet_alerts().await.is_err());
assert!(handler
.handle_telemetry_fleet_node_history(Some(json!({"node_id":"independent"})))
.await
.is_err());
assert!(handler
.handle_telemetry_ingest(Some(
json!({"node_id":"new-claim", "version":"spoof", "reported_at":"now"})
))
.await
.is_err());
assert!(!root.join("new-claim.json").exists());
assert_eq!(
tokio::fs::read(&nodes_path).await.unwrap(),
b"{broken-authoritative-store"
);
}
}
+1 -1
View File
@@ -136,7 +136,7 @@ impl RpcHandler {
/// ~30% of UI calls error out even though the node is perfectly healthy. /// ~30% of UI calls error out even though the node is perfectly healthy.
/// With retry + backoff, the UI sees a uniform slow-but-successful /// With retry + backoff, the UI sees a uniform slow-but-successful
/// response instead of intermittent failures. /// response instead of intermittent failures.
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>( pub(in crate::api::rpc) async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
&self, &self,
client: &reqwest::Client, client: &reqwest::Client,
method: &str, method: &str,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,435 @@
use super::*;
#[test]
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
use base64::Engine;
for paid in [0, 1] {
let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid);
assert_eq!(response["data"], response["data_base64"]);
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(response["data"].as_str().unwrap())
.unwrap(),
[0, 255, 123]
);
assert_eq!(response["size"], 3);
assert_eq!(response["size_bytes"], 3);
assert_eq!(response["paid_sats"], paid);
assert_eq!(response["owned"], true);
}
}
#[tokio::test]
async fn files_copy_routes_media_and_sanitizes_the_filename() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::create_dir(dir.path().join("filebrowser"))
.await
.unwrap();
for (mime, folder) in [
("image/png", "Photos"),
("video/mp4", "Photos"),
("audio/mpeg", "Music"),
("text/plain", "Documents"),
] {
crate::content_owned::record_purchase(
dir.path(),
"seller.onion",
"id",
"../name #?.bin",
mime,
b"paid",
1,
"cashu",
"now",
)
.await
.unwrap();
let item = crate::content_owned::list_owned_checked(dir.path())
.await
.unwrap()
.remove(0);
let relative = file_cached_purchase_in_files(dir.path(), &item)
.await
.unwrap();
assert!(relative.starts_with(&format!("{folder}/name #?")));
assert_eq!(
tokio::fs::read(dir.path().join("filebrowser").join(relative))
.await
.unwrap(),
b"paid"
);
}
}
#[tokio::test]
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
let dir = tempfile::tempdir().unwrap();
crate::content_owned::record_purchase(
dir.path(),
"seller.onion",
"id",
"name",
"text/plain",
b"bytes",
1,
"cashu",
"now",
)
.await
.unwrap();
let item = crate::content_owned::list_owned_checked(dir.path())
.await
.unwrap()
.remove(0);
assert!(file_cached_purchase_in_files(dir.path(), &item)
.await
.is_err());
assert!(!dir.path().join("filebrowser").exists());
}
#[test]
fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
let message = seller_error_message(status, r#"{"error":"Cannot read file\n\u0000"}"#);
assert!(message.starts_with("Seller response (503"));
assert!(message.ends_with("Cannot read file"));
assert!(!message.contains('\n') && !message.contains('\0'));
let body = serde_json::json!({"error": "é".repeat(1000)}).to_string();
assert!(seller_error_message(status, &body).chars().count() < 300);
for body in ["not JSON", r#"{"error": 7}"#, r#"{"error":" "}"#] {
assert_eq!(
seller_error_message(status, body),
"Peer returned an error (503 Service Unavailable)."
);
}
}
#[tokio::test]
async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavailable() {
let dir = tempfile::tempdir().unwrap();
assert!(
existing_paid_content(dir.path(), "seller.onion", "id", None, false)
.await
.unwrap()
.is_none()
);
crate::content_owned::record_purchase(
dir.path(),
"seller.onion",
"id",
"file.txt",
"text/plain",
b"paid",
1,
"cashu",
"now",
)
.await
.unwrap();
for (id, filename) in [("id", None), ("duplicate-id", Some("/file.txt"))] {
let cached = existing_paid_content(dir.path(), "seller.onion", id, filename, false)
.await
.unwrap()
.unwrap();
assert_eq!(cached["paid_sats"], 0);
assert_eq!(cached["already_owned"], true);
assert_eq!(cached["data"], "cGFpZA==");
}
assert!(
existing_paid_content(dir.path(), "different.onion", "id", None, false)
.await
.unwrap()
.is_none()
);
tokio::fs::remove_file(dir.path().join("purchased-content/seller.onion/id"))
.await
.unwrap();
assert!(
existing_paid_content(dir.path(), "seller.onion", "id", None, false)
.await
.unwrap_err()
.to_string()
.contains("No new payment")
);
tokio::fs::write(dir.path().join("purchased-content/owned.json"), b"damaged")
.await
.unwrap();
assert!(
existing_paid_content(dir.path(), "seller.onion", "other-id", None, false)
.await
.unwrap_err()
.to_string()
.contains("no new payment")
);
assert_eq!(
tokio::fs::read(dir.path().join("purchased-content/owned.json"))
.await
.unwrap(),
b"damaged"
);
}
#[tokio::test]
async fn inline_download_limits_known_and_chunked_bodies_without_draining_them() {
use futures_util::StreamExt;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
let response: reqwest::Response = hyper::Response::new("small").into();
assert!(bounded_content_bytes(response, 4).await.is_err());
let response: reqwest::Response = hyper::Response::new("small").into();
assert_eq!(bounded_content_bytes(response, 5).await.unwrap(), b"small");
let consumed = Arc::new(AtomicUsize::new(0));
let counter = consumed.clone();
let chunks = futures_util::stream::iter(0..1000).map(move |_| {
counter.fetch_add(1, Ordering::SeqCst);
Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"abc"))
});
let body = reqwest::Body::wrap_stream(chunks);
let response: reqwest::Response = hyper::Response::new(body).into();
assert!(bounded_content_bytes(response, 4).await.is_err());
assert_eq!(consumed.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn onchain_delivery_streams_to_owned_cache_and_preserves_incomplete_recovery() {
use tokio::io::AsyncReadExt;
let dir = tempfile::tempdir().unwrap();
let response: reqwest::Response = hyper::Response::builder()
.header("content-length", "2097152")
.body(hyper::Body::from(vec![71u8; 2 * 1024 * 1024]))
.unwrap()
.into();
let item = cache_peer_response(
dir.path(),
"seller.onion",
"film",
"film.mp4",
"video/mp4",
1,
"onchain",
response,
)
.await
.unwrap();
assert!(item.download_complete);
assert_eq!(item.ecash_backend, "onchain");
let (_, mut file) = crate::content_owned::open_owned(dir.path(), "seller.onion", "film")
.await
.unwrap()
.unwrap();
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).await.unwrap();
assert_eq!(bytes, vec![71u8; 2 * 1024 * 1024]);
let reply = cached_purchase_response(dir.path(), "seller.onion", "film", true, 0)
.await
.unwrap();
assert_eq!(reply["owned"], true);
assert!(reply.get("data").is_none());
let truncated: reqwest::Response = hyper::Response::builder()
.header("content-length", "100")
.body(hyper::Body::wrap_stream(futures_util::stream::iter([
Ok(bytes::Bytes::from_static(b"short")),
Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"connection closed before delivery completed",
)),
])))
.unwrap()
.into();
assert!(cache_peer_response(
dir.path(),
"seller.onion",
"interrupted",
"film.mp4",
"video/mp4",
1,
"onchain",
truncated
)
.await
.is_err());
let entries = crate::content_owned::list_owned_checked(dir.path())
.await
.unwrap();
assert!(
!entries
.iter()
.find(|item| item.content_id == "interrupted")
.unwrap()
.download_complete
);
assert!(
existing_paid_content(dir.path(), "seller.onion", "interrupted", None, true)
.await
.is_err()
);
}
#[test]
fn share_creation_stays_hidden_without_explicit_visibility_and_rejects_invalid_policy() {
let empty = serde_json::json!({});
assert!(matches!(
parse_content_availability(&empty, "nobody").unwrap(),
Availability::Nobody
));
assert!(matches!(
parse_content_access(&empty).unwrap(),
AccessControl::Free
));
for invalid in [
serde_json::json!({"access":null}),
serde_json::json!({"access":"paid","price_sats":0}),
serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":["unsupported"]}),
serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":"ecash"}),
] {
assert!(parse_content_access(&invalid).is_err());
}
let paid = serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":["ecash"],"availability":"all_peers"});
assert!(matches!(
parse_content_access(&paid).unwrap(),
AccessControl::Paid { price_sats: 1, .. }
));
assert!(matches!(
parse_content_availability(&paid, "nobody").unwrap(),
Availability::AllPeers
));
}
#[tokio::test]
async fn repeated_share_returns_stable_id_and_complete_policy() {
let dir = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = dir.path().to_path_buf();
config.dev_mode = false;
let sessions = crate::session::SessionStore::new_for_tests(dir.path().join("sessions.json"));
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
sessions,
None,
None,
)
.await
.unwrap();
let first = handler
.handle_content_publish(Some(serde_json::json!({
"filename":"film.mp4", "access":"paid", "price_sats":1,
"accepted_methods":["ecash"], "availability":"nobody"
})))
.await
.unwrap();
let second = handler
.handle_content_publish(Some(serde_json::json!({
"filename":"film.mp4", "access":"paid", "price_sats":2,
"accepted_methods":["lightning"], "availability":"nobody"
})))
.await
.unwrap();
assert_eq!(first["item"]["id"], second["item"]["id"]);
let catalog = content_server::load_catalog(dir.path()).await.unwrap();
assert_eq!(catalog.items.len(), 1);
let item = &catalog.items[0];
assert_eq!(second["item"]["id"].as_str(), Some(item.id.as_str()));
assert!(
matches!(&item.access, AccessControl::Paid { price_sats: 2, accepted } if accepted == &["lightning"])
);
assert!(matches!(item.availability, Availability::Nobody));
assert!(handler
.handle_content_configure(Some(serde_json::json!({
"id":item.id, "access":"free"
})))
.await
.is_err());
assert!(matches!(
content_server::load_catalog(dir.path())
.await
.unwrap()
.items[0]
.access,
AccessControl::Paid { price_sats: 2, .. }
));
}
#[tokio::test]
async fn legacy_payment_routes_refuse_fresh_spending_but_preserve_paid_cache() {
let data = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = data.path().to_path_buf();
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let onion = format!("{}.onion", "a".repeat(56));
for method in ["cashu", "fedimint", "auto"] {
let error = handler
.handle_content_download_peer_paid(Some(serde_json::json!({
"onion": onion, "content_id": "file", "price_sats": 1,
"method": method, "cache_only": true
})))
.await
.unwrap_err();
assert!(
error.to_string().contains("No payment was sent"),
"{error:#}"
);
}
assert!(handler
.handle_content_request_invoice(None)
.await
.unwrap_err()
.to_string()
.contains("saved Lightning purchase flow"));
assert!(handler
.handle_content_request_onchain(None)
.await
.unwrap_err()
.to_string()
.contains("saved Bitcoin purchase flow"));
assert!(!data.path().join("wallet").exists());
crate::content_owned::record_purchase(
data.path(),
&onion,
"file",
"paid.txt",
"text/plain",
b"previously paid",
1,
"fedimint",
"2026-10-01T00:00:00Z",
)
.await
.unwrap();
for cache_only in [true, false] {
let result = handler
.handle_content_download_peer_paid(Some(serde_json::json!({
"onion": onion, "content_id": "file", "price_sats": 1,
"method": "fedimint", "cache_only": cache_only
})))
.await
.unwrap();
assert_eq!(result["paid_sats"], 0);
assert_eq!(result["already_owned"], true);
if cache_only {
assert_eq!(result["owned"], true);
} else {
use base64::Engine;
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(result["data"].as_str().unwrap())
.unwrap(),
b"previously paid"
);
}
}
assert!(!data.path().join("wallet").exists());
}
@@ -132,6 +132,9 @@ impl RpcHandler {
"lnd.newaddress" => self.handle_lnd_newaddress().await, "lnd.newaddress" => self.handle_lnd_newaddress().await,
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await, "lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await, "lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.bump-quote" => self.handle_lnd_bump_quote(params).await,
"lnd.bump-submit" => self.handle_lnd_bump_submit(params).await,
"lnd.bump-status" => self.handle_lnd_bump_status(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await, "lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await, "lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await, "lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
@@ -269,6 +272,8 @@ impl RpcHandler {
"wallet.ecash-network" => self.handle_wallet_ecash_network().await, "wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await, "wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await, "wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-lnaddress" => self.handle_wallet_ecash_lnaddress().await,
"wallet.ecash-lnaddress-claim" => self.handle_wallet_ecash_lnaddress_claim().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await, "wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await, "wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await, "wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
@@ -317,6 +322,8 @@ impl RpcHandler {
// Content catalog management // Content catalog management
"content.list-mine" => self.handle_content_list_mine().await, "content.list-mine" => self.handle_content_list_mine().await,
"content.add" => self.handle_content_add(params).await, "content.add" => self.handle_content_add(params).await,
"content.publish" => self.handle_content_publish(params).await,
"content.configure" => self.handle_content_configure(params).await,
"content.remove" => self.handle_content_remove(params).await, "content.remove" => self.handle_content_remove(params).await,
"content.set-pricing" => self.handle_content_set_pricing(params).await, "content.set-pricing" => self.handle_content_set_pricing(params).await,
"content.set-availability" => self.handle_content_set_availability(params).await, "content.set-availability" => self.handle_content_set_availability(params).await,
@@ -325,6 +332,43 @@ impl RpcHandler {
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await, "content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.indeehub-projects" => self.handle_content_indeehub_projects().await, "content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"content.browse-all-peers" => self.handle_content_browse_all_peers().await, "content.browse-all-peers" => self.handle_content_browse_all_peers().await,
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
"content.playback-prepare" => self.handle_playback_prepare(params, session_token).await,
"content.playback-start" => self.handle_playback_start(params, session_token).await,
"content.playback-status" => self.handle_playback_status(params, session_token).await,
"content.rental-purchase" => self.handle_content_rental_purchase(params).await,
"content.onchain-cancel" => self.handle_onchain_operation(params, "cancel").await,
"content.onchain-attempt" => self.handle_onchain_operation(params, "lookup").await,
"content.onchain-create" => self.handle_onchain_operation(params, "create").await,
"content.onchain-expose" => self.handle_onchain_operation(params, "expose").await,
"content.onchain-prepare" => self.handle_onchain_operation(params, "prepare").await,
"content.onchain-pay" => self.handle_onchain_operation(params, "pay").await,
"content.onchain-recover" => self.handle_onchain_operation(params, "status").await,
"content.onchain-download" => self.handle_onchain_operation(params, "download").await,
"content.invoice-pay" => self.handle_lightning_operation(params, "pay").await,
"content.invoice-download" => self.handle_lightning_operation(params, "download").await,
"content.invoice-attempt" => self.handle_lightning_operation(params, "lookup").await,
"content.invoice-retry-native" => {
self.handle_lightning_operation(params, "retry").await
}
"content.invoice-create" => self.handle_lightning_operation(params, "create").await,
"content.invoice-recover" => self.handle_lightning_operation(params, "status").await,
"content.invoice-cancel" => self.handle_lightning_operation(params, "cancel").await,
"content.purchase" => self.handle_content_purchase(params).await,
"content.cancel-purchase" => self.handle_content_cancel_purchase(params).await,
"content.payment-status" => self.handle_content_payment_status(params).await,
"media.registration.context" => {
self.handle_media_registration_context(params.unwrap_or_default())
.await
}
"media.registration.prepare" => {
self.handle_media_registration_prepare(params.unwrap_or_default())
.await
}
"media.registration.resolve" => {
self.handle_media_registration_resolve(params.unwrap_or_default())
.await
}
"content.owned-list" => self.handle_content_owned_list().await, "content.owned-list" => self.handle_content_owned_list().await,
"content.owned-get" => self.handle_content_owned_get(params).await, "content.owned-get" => self.handle_content_owned_get(params).await,
"content.request-invoice" => self.handle_content_request_invoice(params).await, "content.request-invoice" => self.handle_content_request_invoice(params).await,
@@ -7,6 +7,8 @@ use crate::mesh;
use crate::network::dwn_store::DwnStore; use crate::network::dwn_store::DwnStore;
use crate::nostr_handshake; use crate::nostr_handshake;
use anyhow::Result; use anyhow::Result;
use futures_util::stream::{FuturesUnordered, StreamExt};
use std::sync::Arc;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1"; const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
@@ -460,37 +462,65 @@ impl RpcHandler {
let identity_dir = self.config.data_dir.join("identity"); let identity_dir = self.config.data_dir.join("identity");
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?; let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
let mut synced = 0u32; // A dead Tor peer can take the bounded transport timeout. Serialising
let mut failed = 0u32; // those waits made one bad peer block every healthy peer behind it.
// Keep concurrency bounded so a large federation cannot exhaust the
// node's sockets or overwhelm a peer, while allowing healthy peers to
// finish independently. Results are tagged and sorted below so the
// response remains stable for callers and tests.
const MAX_CONCURRENT_SYNCS: usize = 4;
let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_SYNCS));
let identity = Arc::new(node_identity);
let data_dir = self.config.data_dir.clone();
let mut pending = FuturesUnordered::new();
for (index, node) in nodes
.into_iter()
.filter(|node| node.trust_level != TrustLevel::Untrusted)
.enumerate()
{
let data_dir = data_dir.clone();
let local_did = local_did.clone();
let identity = identity.clone();
let semaphore = semaphore.clone();
pending.push(async move {
let permit = semaphore
.acquire_owned()
.await
.expect("sync semaphore lives for all pending syncs");
let result = federation::sync_with_peer(&data_dir, &node, &local_did, |bytes| {
identity.sign(bytes)
})
.await;
drop(permit);
(index, node.did, result)
});
}
let mut results = Vec::new(); let mut results = Vec::new();
while let Some((index, did, result)) = pending.next().await {
for node in &nodes { let row = match result {
if node.trust_level == TrustLevel::Untrusted { Ok(state) => serde_json::json!({
continue; "index": index,
} "did": did,
"status": "ok",
let did_clone = local_did.clone(); "apps": state.apps.len(),
match federation::sync_with_peer(&self.config.data_dir, node, &did_clone, |bytes| { }),
node_identity.sign(bytes) Err(e) => serde_json::json!({
}) "index": index,
.await "did": did,
{ "status": "error",
Ok(state) => { "error": e.to_string(),
synced += 1; }),
results.push(serde_json::json!({ };
"did": node.did, results.push(row);
"status": "ok", }
"apps": state.apps.len(), results.sort_by_key(|row| row["index"].as_u64().unwrap_or(u64::MAX));
})); let synced = results.iter().filter(|row| row["status"] == "ok").count() as u32;
} let failed = results.len() as u32 - synced;
Err(e) => { for row in &mut results {
failed += 1; if let Some(object) = row.as_object_mut() {
results.push(serde_json::json!({ object.remove("index");
"did": node.did,
"status": "error",
"error": e.to_string(),
}));
}
} }
} }
@@ -572,14 +602,39 @@ impl RpcHandler {
None None
}; };
// Reuse the minute collector instead of running expensive probes for
// every peer. An absent/stalled collector is unknown, never zero load.
let now = chrono::Utc::now().timestamp();
let latest = self
.metrics_store
.latest()
.await
.filter(|sample| (0..=180).contains(&now.saturating_sub(sample.timestamp)));
let metrics = latest.as_ref().map(|sample| &sample.system);
let uptime = tokio::fs::read_to_string("/proc/uptime")
.await
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.filter(|v| v.is_finite() && *v >= 0.0)
.map(|v| v as u64);
let state = federation::build_local_state( let state = federation::build_local_state(
apps, apps,
0.0, metrics
0, .map(|m| m.cpu_percent)
0, .filter(|v| v.is_finite() && (0.0..=100.0).contains(v)),
0, metrics
0, .filter(|m| m.mem_total_bytes > 0)
0, .map(|m| m.mem_used_bytes),
metrics
.filter(|m| m.mem_total_bytes > 0)
.map(|m| m.mem_total_bytes),
metrics
.filter(|m| m.disk_total_bytes > 0)
.map(|m| m.disk_used_bytes),
metrics
.filter(|m| m.disk_total_bytes > 0)
.map(|m| m.disk_total_bytes),
uptime,
tor_active, tor_active,
server_name, server_name,
nostr_npub, nostr_npub,
@@ -1224,76 +1279,120 @@ impl RpcHandler {
); );
} }
let reply = self.prepare_peer_approval_reply(&req).await?;
// Persist the operator decision before transport. A relay outage must
// not require another approval or lose the already-authorized reply.
pending::decide(&self.config.data_dir, id, pending::PendingState::Approved).await?;
let delivered = self.deliver_peer_approval_reply(&reply).await?;
Ok(serde_json::json!({ "approved": true, "id": id, "delivery_pending": !delivered }))
}
async fn prepare_peer_approval_reply(
&self,
req: &pending::PendingPeerRequest,
) -> Result<federation::handshake_delivery::ApprovalReply> {
use federation::handshake_delivery::{self, ApprovalReply};
if let Some(reply) = handshake_delivery::find(&self.config.data_dir, &req.id).await? {
anyhow::ensure!(
reply.recipient == req.from_nostr_pubkey && reply.expected_did == req.from_did,
"Approval recipient changed"
);
return Ok(reply);
}
let (data, _) = self.state_manager.get_snapshot().await; let (data, _) = self.state_manager.get_snapshot().await;
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let local_onion = data let local_onion = data
.server_info .server_info
.tor_address .tor_address
.clone() .as_deref()
.ok_or_else(|| anyhow::anyhow!("Tor address not available"))?; .ok_or_else(|| anyhow::anyhow!("Tor address not available"))?;
let local_pubkey = data.server_info.pubkey.clone(); let local_fips_npub = identity::fips_npub(&self.config.data_dir.join("identity"))
.await
// Generate a one-shot federation invite. The code embeds OUR onion .unwrap_or(None);
// and OUR pubkey, but it leaves this box only inside the NIP-44
// ciphertext below.
let identity_dir = self.config.data_dir.join("identity");
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
// Discovery/connection-request approvals admit the requester as
// Observer — the invite itself now carries that level, so both
// sides converge on Observer without post-hoc demotion.
let invite_code = federation::create_invite( let invite_code = federation::create_invite(
&self.config.data_dir, &self.config.data_dir,
&local_did, &local_did,
&local_onion, local_onion,
&local_pubkey, &data.server_info.pubkey,
local_fips_npub.as_deref(), local_fips_npub.as_deref(),
TrustLevel::Observer, TrustLevel::Observer,
) )
.await?; .await?;
handshake_delivery::stage(
&self.config.data_dir,
ApprovalReply {
request_id: req.id.clone(),
recipient: req.from_nostr_pubkey.clone(),
expected_did: req.from_did.clone(),
invite_code,
attempts: 0,
next_attempt: 0,
},
)
.await
}
// Pre-add the requester to OUR federation list as Observer so that async fn deliver_peer_approval_reply(
// when their `federation.peer-joined` callback arrives over Tor we &self,
// already trust their pubkey enough to accept the join. Their DID reply: &federation::handshake_delivery::ApprovalReply,
// and pubkey come from the request — we'll cross-check the pubkey ) -> Result<bool> {
// against the eventual peer-joined signature in the existing let Some(claimed) = federation::handshake_delivery::claim(
// verification path (handlers.rs line ~365). &self.config.data_dir,
if !req.from_did.is_empty() { &reply.request_id,
// We don't know the requester's onion or ed25519 pubkey yet — chrono::Utc::now().timestamp(),
// they'll send those in the federation.peer-joined callback )
// after they apply our invite. Until then we can't add a real .await?
// FederatedNode entry. We just store the pending row as else {
// Approved so the UI shows progress, and trust the existing return Ok(false);
// peer-joined handler to admit them as Observer when they call. };
// let result = nostr_handshake::send_peer_invite(
// Caveat: peer-joined currently hardcodes TrustLevel::Trusted. &self.config.data_dir.join("identity"),
// We override that below by demoting on success. &claimed.recipient,
debug!( &claimed.invite_code,
requester_did = %req.from_did, &self.handshake_relays().await,
"Approval pending — waiting for federation.peer-joined callback over Tor"
);
}
// Encrypt + send the invite over NIP-44 to the requester.
let identity_dir = self.config.data_dir.join("identity");
nostr_handshake::send_peer_invite(
&identity_dir,
&req.from_nostr_pubkey,
&invite_code,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(), self.config.nostr_tor_proxy.as_deref(),
) )
.await?; .await;
if result.is_err() {
warn!(request_id = %reply.request_id, "Peer approval delivery deferred; durable retry scheduled");
}
Ok(result.is_ok())
}
pending::set_state(&self.config.data_dir, id, pending::PendingState::Approved).await?; /// Recover relay loss and legacy approvals without changing trust or
info!( /// resurrecting a node that the operator explicitly removed.
id = %id, pub(in crate::api::rpc) async fn retry_peer_approval_replies(&self) -> Result<()> {
from = %req.from_nostr_pubkey, let requests = pending::load_pending(&self.config.data_dir).await?;
"Approved peer request and shipped invite over NIP-44" let nodes = federation::load_nodes(&self.config.data_dir).await?;
); let removed = federation::load_removed_dids(&self.config.data_dir).await?;
Ok(serde_json::json!({ let cutoff = chrono::Utc::now() - chrono::Duration::days(30);
"approved": true, let mut sent = 0;
"id": id, for req in requests {
})) if req.outbound || req.state != pending::PendingState::Approved {
federation::handshake_delivery::remove(&self.config.data_dir, &req.id).await?;
continue;
}
let expired = chrono::DateTime::parse_from_rfc3339(&req.received_at)
.map(|time| time < cutoff)
.unwrap_or(true);
if expired
|| removed.contains(&req.from_did)
|| nodes.iter().any(|node| node.did == req.from_did)
{
federation::handshake_delivery::remove(&self.config.data_dir, &req.id).await?;
continue;
}
if req.from_did.is_empty() || sent >= 4 {
continue;
}
let reply = self.prepare_peer_approval_reply(&req).await?;
if reply.next_attempt > chrono::Utc::now().timestamp() {
continue;
}
self.deliver_peer_approval_reply(&reply).await?;
sent += 1;
}
Ok(())
} }
/// federation.reject-request — drop a pending request and, if requested, /// federation.reject-request — drop a pending request and, if requested,
@@ -1323,19 +1422,19 @@ impl RpcHandler {
); );
} }
pending::decide(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
if notify { if notify {
let identity_dir = self.config.data_dir.join("identity"); let identity_dir = self.config.data_dir.join("identity");
let _ = nostr_handshake::send_peer_reject( let _ = nostr_handshake::send_peer_reject(
&identity_dir, &identity_dir,
&req.from_nostr_pubkey, &req.from_nostr_pubkey,
reason, reason,
&self.config.nostr_relays, &self.handshake_relays().await,
self.config.nostr_tor_proxy.as_deref(), self.config.nostr_tor_proxy.as_deref(),
) )
.await; .await;
} }
pending::set_state(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request"); info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request");
Ok(serde_json::json!({ "rejected": true, "id": id })) Ok(serde_json::json!({ "rejected": true, "id": id }))
} }
@@ -1361,16 +1460,7 @@ impl RpcHandler {
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(true); .unwrap_or(true);
let req = pending::find_by_id(&self.config.data_dir, id) let req = pending::cancel_outbound(&self.config.data_dir, id).await?;
.await?
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
if !req.outbound || !matches!(req.state, pending::PendingState::Sent) {
anyhow::bail!(
"Can only cancel outbound requests in Sent state (outbound={}, state={:?})",
req.outbound,
req.state
);
}
if notify { if notify {
let identity_dir = self.config.data_dir.join("identity"); let identity_dir = self.config.data_dir.join("identity");
@@ -1380,7 +1470,7 @@ impl RpcHandler {
&identity_dir, &identity_dir,
&req.from_nostr_pubkey, &req.from_nostr_pubkey,
reason, reason,
&self.config.nostr_relays, &self.handshake_relays().await,
self.config.nostr_tor_proxy.as_deref(), self.config.nostr_tor_proxy.as_deref(),
) )
.await .await
@@ -1393,7 +1483,6 @@ impl RpcHandler {
} }
} }
pending::delete(&self.config.data_dir, id).await?;
info!(id = %id, to = %req.from_nostr_pubkey, notified = notify, "Cancelled outbound peer request"); info!(id = %id, to = %req.from_nostr_pubkey, notified = notify, "Cancelled outbound peer request");
Ok(serde_json::json!({ "cancelled": true, "id": id, "notified": notify })) Ok(serde_json::json!({ "cancelled": true, "id": id, "notified": notify }))
} }
@@ -0,0 +1,455 @@
//! Exercise encrypted replies through a relay configured in the UI only.
use crate::federation::pending::{self, PendingState};
use futures_util::{SinkExt, StreamExt};
use nostr_sdk::prelude::{nip44, Event, Keys};
use std::sync::Arc;
use std::time::Duration;
#[tokio::test]
async fn managed_relay_receives_approval_rejection_and_cancellation() {
for (operation, accepted) in [
("approve", true),
("reject", true),
("cancel", true),
("approve", false),
("retry", true),
] {
let tmp = tempfile::tempdir().unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_url = format!("ws://{}", listener.local_addr().unwrap());
let relay = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(socket).await.unwrap();
while let Some(Ok(message)) = ws.next().await {
if !message.is_text() {
continue;
}
let value: serde_json::Value =
serde_json::from_str(message.to_text().unwrap()).unwrap();
if value[0] != "EVENT" {
continue;
}
let event: Event = serde_json::from_value(value[1].clone()).unwrap();
event.verify().unwrap();
ws.send(tokio_tungstenite::tungstenite::Message::Text(
serde_json::json!([
"OK",
event.id.to_hex(),
accepted,
"blocked: fixture rejection"
])
.to_string(),
))
.await
.unwrap();
return event;
}
panic!("relay closed without a signed event");
});
let mut config = crate::config::Config::default();
config.data_dir = tmp.path().to_path_buf();
config.nostr_relays.clear();
config.nostr_tor_proxy = None;
crate::nostr_relays::save_relays(
tmp.path(),
&crate::nostr_relays::RelayStore {
relays: vec![crate::nostr_relays::RelayConfig {
url: relay_url,
enabled: true,
added_at: chrono::Utc::now().to_rfc3339(),
}],
},
)
.await
.unwrap();
let sender = Keys::parse(&"11".repeat(32)).unwrap();
let recipient = Keys::parse(&"22".repeat(32)).unwrap();
let identity_dir = tmp.path().join("identity");
tokio::fs::create_dir_all(&identity_dir).await.unwrap();
tokio::fs::write(identity_dir.join("nostr_secret"), "11".repeat(32))
.await
.unwrap();
tokio::fs::write(identity_dir.join("node_key"), [0x33; 32])
.await
.unwrap();
let state = Arc::new(crate::state::StateManager::new());
state
.mutate_data(|data| {
data.server_info.pubkey = "33".repeat(32);
data.server_info.tor_address = Some(format!("{}.onion", "a".repeat(56)));
})
.await;
let handler = crate::api::rpc::RpcHandler::new(
config,
state,
Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let row = if operation == "cancel" {
pending::insert_outbound(
tmp.path(),
recipient.public_key().to_hex(),
String::new(),
crate::identity::did_key_from_pubkey_hex(&"44".repeat(32)).unwrap(),
None,
None,
)
.await
.unwrap()
} else {
pending::insert_inbound(
tmp.path(),
recipient.public_key().to_hex(),
String::new(),
crate::identity::did_key_from_pubkey_hex(&"44".repeat(32)).unwrap(),
None,
None,
)
.await
.unwrap()
.unwrap()
};
if operation == "retry" {
pending::decide(tmp.path(), &row.id, PendingState::Approved)
.await
.unwrap();
}
let params = Some(serde_json::json!({"id": row.id, "notify": true}));
let action = async {
match operation {
"approve" => handler.handle_federation_approve_request(params).await,
"reject" => handler.handle_federation_reject_request(params).await,
"retry" => handler
.retry_peer_approval_replies()
.await
.map(|_| serde_json::json!({"ok": true})),
_ => handler.handle_federation_cancel_request(params).await,
}
};
let outcome = tokio::time::timeout(Duration::from_secs(20), action)
.await
.unwrap();
assert_eq!(outcome.is_ok(), accepted || operation == "approve");
let event = tokio::time::timeout(Duration::from_secs(5), relay)
.await
.unwrap()
.unwrap();
assert_eq!(event.pubkey, sender.public_key());
let plaintext =
nip44::decrypt(recipient.secret_key(), &event.pubkey, &event.content).unwrap();
let message: serde_json::Value = serde_json::from_str(&plaintext).unwrap();
let expected = match operation {
"approve" | "retry" => "peer-invite",
"reject" => "peer-reject",
_ => "peer-cancel",
};
assert_eq!(message["type"], expected);
let saved = pending::find_by_id(tmp.path(), &row.id).await.unwrap();
if !accepted {
assert_eq!(
saved.unwrap().state,
if operation == "approve" {
PendingState::Approved
} else {
PendingState::Pending
}
);
if operation == "approve" {
assert_eq!(outcome.unwrap()["delivery_pending"], true);
let durable = crate::federation::handshake_delivery::find(tmp.path(), &row.id)
.await
.unwrap()
.unwrap();
assert_eq!(durable.recipient, recipient.public_key().to_hex());
assert_eq!(durable.attempts, 1);
}
assert!(crate::federation::load_nodes(tmp.path())
.await
.unwrap()
.is_empty());
continue;
}
match operation {
"approve" | "retry" => {
assert_eq!(saved.unwrap().state, PendingState::Approved);
let first = crate::federation::handshake_delivery::find(tmp.path(), &row.id)
.await
.unwrap()
.unwrap();
assert_eq!(first.attempts, 1);
// Concurrent/background polling honors the persisted backoff.
handler.retry_peer_approval_replies().await.unwrap();
let second = crate::federation::handshake_delivery::find(tmp.path(), &row.id)
.await
.unwrap()
.unwrap();
assert_eq!(second.attempts, 1);
assert_eq!(first.invite_code, second.invite_code);
let invite =
crate::federation::parse_invite(message["invite_code"].as_str().unwrap())
.unwrap();
assert_eq!(invite.trust_level, crate::federation::TrustLevel::Observer);
assert!(!event.content.contains(".onion"));
}
"reject" => assert_eq!(saved.unwrap().state, PendingState::Rejected),
_ => assert!(saved.is_none()),
}
}
}
#[tokio::test]
async fn federation_metrics_are_collected_values_or_unknown_never_placeholders() {
for age in [None, Some(0), Some(181), Some(-120)] {
let tmp = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = tmp.path().to_path_buf();
let metrics = Arc::new(crate::monitoring::MetricsStore::new());
if let Some(age) = age {
metrics
.push(
serde_json::from_value(serde_json::json!({
"timestamp": chrono::Utc::now().timestamp() - age,
"system": {"cpu_percent": 37.5, "mem_used_bytes": 200,
"mem_total_bytes": 800, "disk_used_bytes": 600,
"disk_total_bytes": 1000, "net_rx_bytes": 0, "net_tx_bytes": 0,
"load_avg_1": 0.0, "load_avg_5": 0.0, "load_avg_15": 0.0},
"containers": [], "rpc_latency_ms": 0.0, "ws_connections": 0
}))
.unwrap(),
)
.await;
}
let handler = crate::api::rpc::RpcHandler::new(
config,
Arc::new(crate::state::StateManager::new()),
metrics,
crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let snapshot = handler.handle_federation_get_state().await.unwrap();
if age == Some(0) {
assert_eq!(snapshot["cpu_usage_percent"], 37.5);
assert_eq!(snapshot["mem_used_bytes"], 200);
assert_eq!(snapshot["disk_total_bytes"], 1000);
} else {
for field in [
"cpu_usage_percent",
"mem_used_bytes",
"mem_total_bytes",
"disk_used_bytes",
"disk_total_bytes",
] {
assert!(
snapshot.get(field).is_none_or(|v| v.is_null()),
"{age:?}: {field}"
);
}
}
let peer = serde_json::from_value(serde_json::json!({
"did": "did:key:test", "pubkey": "11".repeat(32), "onion": "test.onion",
"trust_level": "trusted", "added_at": chrono::Utc::now().to_rfc3339(),
"last_state": snapshot
}))
.unwrap();
crate::federation::save_nodes(tmp.path(), &[peer])
.await
.unwrap();
let fleet = handler.handle_telemetry_fleet_status().await.unwrap();
let report = &fleet["nodes"][0];
if age == Some(0) {
assert_eq!(report["cpu_pct"], 38.0);
assert_eq!(report["mem_pct"], 25.0);
assert_eq!(report["disk_pct"], 60.0);
} else {
for field in ["cpu_pct", "mem_pct", "disk_pct"] {
assert!(report[field].is_null(), "{age:?}: {field}");
}
}
}
}
/// Real encrypted relay -> poll -> persisted membership, including the normal
/// npub-only outbound request whose DID is unknown until the authenticated reply.
#[tokio::test]
async fn npub_only_request_accepts_bound_reply_but_rejects_other_sender_and_forged_did() {
use base64::Engine;
use nostr_sdk::{EventBuilder, Kind, Tag};
let dir = tempfile::tempdir().unwrap();
let local = Keys::parse(&"11".repeat(32)).unwrap();
let remote = Keys::parse(&"22".repeat(32)).unwrap();
let stranger = Keys::parse(&"55".repeat(32)).unwrap();
let remote_key = "44".repeat(32);
let remote_did = crate::identity::did_key_from_pubkey_hex(&remote_key).unwrap();
let payload = serde_json::json!({"did":remote_did,"pubkey":remote_key,"onion":format!("{}.onion","b".repeat(56)),"token":"fixture-invite"});
let event = |sender: &Keys, payload: &serde_json::Value| {
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(payload).unwrap())
);
let content = nip44::encrypt(
sender.secret_key(),
&local.public_key(),
serde_json::json!({"type":"peer-invite","invite_code":code}).to_string(),
nip44::Version::V2,
)
.unwrap();
EventBuilder::new(Kind::EncryptedDirectMessage, content)
.tag(Tag::public_key(local.public_key()))
.sign_with_keys(sender)
.unwrap()
};
let mut forged = payload.clone();
forged["pubkey"] = serde_json::json!("66".repeat(32));
let events = Arc::new(std::sync::Mutex::new(vec![
event(&stranger, &payload),
event(&remote, &forged),
]));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_url = format!("ws://{}", listener.local_addr().unwrap());
let relay_events = events.clone();
let relay = tokio::spawn(async move {
while let Ok((socket, _)) = listener.accept().await {
let events = relay_events.clone();
tokio::spawn(async move {
let Ok(mut ws) = tokio_tungstenite::accept_async(socket).await else {
return;
};
while let Some(Ok(message)) = ws.next().await {
let Ok(text) = message.to_text() else {
continue;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
continue;
};
if value[0] != "REQ" {
continue;
}
let stored = events.lock().unwrap().clone();
for event in stored {
if ws
.send(tokio_tungstenite::tungstenite::Message::Text(
serde_json::json!(["EVENT", value[1], event]).to_string(),
))
.await
.is_err()
{
return;
}
}
if ws
.send(tokio_tungstenite::tungstenite::Message::Text(
serde_json::json!(["EOSE", value[1]]).to_string(),
))
.await
.is_err()
{
return;
}
}
});
}
});
let identity_dir = dir.path().join("identity");
tokio::fs::create_dir_all(&identity_dir).await.unwrap();
tokio::fs::write(identity_dir.join("nostr_secret"), "11".repeat(32))
.await
.unwrap();
let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir)
.await
.unwrap();
tokio::fs::write(
dir.path()
.join(crate::nostr_handshake::DISCOVERY_STATE_FILE),
br#"{"enabled":true}"#,
)
.await
.unwrap();
let mut config = crate::config::Config::default();
config.data_dir = dir.path().into();
config.nostr_relays = vec![relay_url];
config.nostr_tor_proxy = None;
let state = Arc::new(crate::state::StateManager::new());
state
.mutate_data(|data| {
data.server_info.pubkey = identity.pubkey_hex();
data.server_info.tor_address = Some(format!("{}.onion", "a".repeat(56)));
})
.await;
let handler = crate::api::rpc::RpcHandler::new(
config,
state,
Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(dir.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let row = pending::insert_outbound(
dir.path(),
remote.public_key().to_hex(),
String::new(),
String::new(),
None,
None,
)
.await
.unwrap();
let rejected = handler.handle_handshake_poll().await.unwrap();
assert!(rejected["applied_invites"].as_array().unwrap().is_empty());
assert!(crate::federation::load_nodes(dir.path())
.await
.unwrap()
.is_empty());
assert_eq!(
pending::find_by_id(dir.path(), &row.id)
.await
.unwrap()
.unwrap()
.state,
PendingState::Sent
);
*events.lock().unwrap() = vec![event(&remote, &payload)];
let accepted = handler.handle_handshake_poll().await.unwrap();
assert_eq!(accepted["applied_invites"], serde_json::json!([remote_did]));
let nodes = crate::federation::load_nodes(dir.path()).await.unwrap();
assert_eq!(nodes.len(), 1);
assert_eq!(
nodes[0].trust_level,
crate::federation::TrustLevel::Observer
);
assert_eq!(
pending::find_by_id(dir.path(), &row.id)
.await
.unwrap()
.unwrap()
.state,
PendingState::Approved
);
assert_eq!(
pending::find_by_id(dir.path(), &row.id)
.await
.unwrap()
.unwrap()
.from_did,
remote_did
);
let duplicate = handler.handle_handshake_poll().await.unwrap();
assert!(duplicate["applied_invites"].as_array().unwrap().is_empty());
assert_eq!(
crate::federation::load_nodes(dir.path())
.await
.unwrap()
.len(),
1
);
relay.abort();
}
@@ -1,4 +1,6 @@
mod handlers; mod handlers;
#[cfg(test)]
mod handshake_tests;
use anyhow::Result; use anyhow::Result;
+21 -3
View File
@@ -276,6 +276,11 @@ impl RpcHandler {
} }
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"), Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
} }
if load_discovery_state(&self.config.data_dir).await.enabled {
if let Err(error) = self.retry_peer_approval_replies().await {
tracing::warn!("Peer approval retry could not complete: {error:#}");
}
}
} }
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> { pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
@@ -340,6 +345,7 @@ impl RpcHandler {
} }
} }
HandshakeMessage::PeerInvite { invite_code } => { HandshakeMessage::PeerInvite { invite_code } => {
let _decision = pending::outbound_decision_guard().await;
// Match against an outbound Sent request from this nostr // Match against an outbound Sent request from this nostr
// pubkey. If we never sent them anything, ignore — we // pubkey. If we never sent them anything, ignore — we
// don't accept unsolicited invites over Nostr. // don't accept unsolicited invites over Nostr.
@@ -356,6 +362,16 @@ impl RpcHandler {
); );
continue; continue;
}; };
let scoped_invite = match crate::federation::restrict_discovery_invite(
invite_code,
&row.from_did,
) {
Ok(code) => code,
Err(_) => {
tracing::warn!("Rejected peer invite with mismatched identity");
continue;
}
};
let row_id = row.id.clone(); let row_id = row.id.clone();
let (data, _) = self.state_manager.get_snapshot().await; let (data, _) = self.state_manager.get_snapshot().await;
let local_did = let local_did =
@@ -373,7 +389,7 @@ impl RpcHandler {
let local_name = data.server_info.name.clone(); let local_name = data.server_info.name.clone();
match crate::federation::accept_invite( match crate::federation::accept_invite(
&self.config.data_dir, &self.config.data_dir,
invite_code, &scoped_invite,
&local_did, &local_did,
&local_onion, &local_onion,
&local_pubkey, &local_pubkey,
@@ -416,10 +432,11 @@ impl RpcHandler {
.await; .await;
} }
pending::set_state( pending::complete_outbound(
&self.config.data_dir, &self.config.data_dir,
&row_id, &row_id,
PendingState::Approved, &hs.from_nostr_pubkey,
&node.did,
) )
.await?; .await?;
applied_invites.push(node.did); applied_invites.push(node.did);
@@ -434,6 +451,7 @@ impl RpcHandler {
} }
} }
HandshakeMessage::PeerReject { reason } => { HandshakeMessage::PeerReject { reason } => {
let _decision = pending::outbound_decision_guard().await;
let pendings = pending::load_pending(&self.config.data_dir).await?; let pendings = pending::load_pending(&self.config.data_dir).await?;
if let Some(row) = pendings.iter().find(|r| { if let Some(row) = pendings.iter().find(|r| {
r.outbound r.outbound
@@ -1,6 +1,8 @@
use super::*; use super::*;
use crate::api::rpc::RpcHandler; use crate::api::rpc::RpcHandler;
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose}; use crate::identity_manager::{
is_node_identity, IdentityManager, IdentityProfile, IdentityPurpose,
};
use crate::network::did_dht; use crate::network::did_dht;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use nostr_sdk::ToBech32; use nostr_sdk::ToBech32;
@@ -38,7 +40,7 @@ impl RpcHandler {
.into_iter() .into_iter()
.map(|id| { .map(|id| {
let is_default = default_id.as_deref() == Some(&id.id); let is_default = default_id.as_deref() == Some(&id.id);
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex; let is_node = is_node_identity(&id, &node_pubkey_hex);
let (nostr_pubkey, nostr_npub) = if is_node { let (nostr_pubkey, nostr_npub) = if is_node {
( (
node_nostr_hex.clone().or(id.nostr_pubkey), node_nostr_hex.clone().or(id.nostr_pubkey),
@@ -55,6 +57,10 @@ impl RpcHandler {
"did": id.did, "did": id.did,
"created_at": id.created_at, "created_at": id.created_at,
"is_default": is_default, "is_default": is_default,
// The node's operational Nostr key is intentionally
// distinguishable from user profile identities. Clients
// must never offer it in app sign-in pickers.
"is_node": is_node,
"nostr_pubkey": nostr_pubkey, "nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub, "nostr_npub": nostr_npub,
"profile": id.profile, "profile": id.profile,
@@ -0,0 +1,358 @@
use super::RpcHandler;
use crate::{
api::handler::lightning_purchase::{Operation, ROUTE},
content_lightning::{Binding, BuyerRecord, Journal, Phase, Status},
};
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Params {
onion: String,
content_id: String,
price_sats: Option<u64>,
operation_id: Option<String>,
#[serde(default)]
external_exposure: bool,
}
impl RpcHandler {
pub(super) async fn handle_lightning_operation(
&self,
params: Option<serde_json::Value>,
action: &str,
) -> Result<serde_json::Value> {
let params: Params = serde_json::from_value(params.context("Missing invoice operation")?)?;
let peer =
crate::federation::load_unique_payment_peer(&self.config.data_dir, &params.onion)
.await?;
let fips = peer
.fips_npub
.context("Seller has no authenticated mesh connection")?;
let buyer =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?
.did_key()?;
anyhow::ensure!(buyer != peer.did, "Cannot buy a file from this same node");
let _admission = crate::content_payment_admission::lock(
&self.config.data_dir,
&buyer,
&peer.did,
&params.content_id,
)
.await?;
if matches!(action, "create" | "pay" | "retry") || params.external_exposure {
self.ensure_onchain_allows_other_rail(&buyer, &peer.did, &params.content_id)
.await?;
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
anyhow::ensure!(
cashu
.find_buyers(&buyer, &peer.did, &params.content_id)
.await?
.iter()
.all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
"Recover or cancel the original Cashu purchase before exposing a Lightning invoice"
);
}
let journal = Journal::open(&self.config.data_dir).await?;
let original = if let Some(id) = &params.operation_id {
journal.buyer(id)?
} else {
journal.buyer_for(&buyer, &peer.did, &params.content_id)?
};
if let Some(record) = &original {
anyhow::ensure!(
record.binding.buyer_did == buyer
&& record.binding.seller_did == peer.did
&& record.binding.content_id == params.content_id
&& record.seller_onion == params.onion,
"Original invoice belongs to another purchase"
);
}
if action == "lookup" {
return Ok(match original {
None => serde_json::json!({"attempt":null}),
Some(mut record) => {
let mut native_result = record.native_result.clone();
if native_result.is_none() && record.native_dispatched {
if let Some(status) = &record.last {
if let Ok(payment) = self
.handle_lnd_paymentstatus(Some(
serde_json::json!({"payment_hash":status.payment_hash}),
))
.await
{
if let Some(result @ ("failed" | "succeeded")) =
payment["status"].as_str()
{
native_result = Some(result.to_owned());
record.native_result = native_result.clone();
journal.save_buyer(&record)?;
}
}
}
}
let native_failed =
!record.external_exposure && native_result.as_deref() == Some("failed");
let native_succeeded = native_result.as_deref() == Some("succeeded");
if !record.external_exposure {
if let Some(status) = record.last.as_mut() {
status.bolt11 = None;
}
}
serde_json::json!({"attempt":{"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"external_exposure":record.external_exposure,"native_failed":native_failed,"native_succeeded":native_succeeded,"status":record.last}})
}
});
}
let mut record = if let Some(record) = original {
record
} else {
anyhow::ensure!(
action == "create" && params.operation_id.is_none(),
"Original invoice operation is unavailable"
);
BuyerRecord {
binding: Binding {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer.clone(),
seller_did: peer.did.clone(),
content_id: params.content_id.clone(),
price_sats: params
.price_sats
.context("Expected invoice price is required")?,
},
seller_onion: params.onion.clone(),
external_exposure: false,
native_retired: false,
native_replacement: None,
native_dispatched: false,
native_result: None,
last: None,
}
};
anyhow::ensure!(
record.binding.buyer_did == buyer
&& record.binding.seller_did == peer.did
&& record.binding.content_id == params.content_id
&& record.seller_onion == params.onion
&& params
.operation_id
.as_ref()
.is_none_or(|id| id == &record.binding.id),
"Original invoice operation changed"
);
if action == "retry" {
anyhow::ensure!(
params.operation_id.is_some()
&& !params.external_exposure
&& params.price_sats == Some(record.binding.price_sats),
"Explicit original native retry and original price required"
);
if record.native_result.is_none()
&& record.native_dispatched
&& !record.external_exposure
{
let hash = &record
.last
.as_ref()
.context("Original invoice metadata missing")?
.payment_hash;
let payment = self
.handle_lnd_paymentstatus(Some(serde_json::json!({"payment_hash":hash})))
.await?;
if matches!(payment["status"].as_str(), Some("failed" | "succeeded")) {
record.native_result = payment["status"].as_str().map(str::to_owned);
journal.save_buyer(&record)?;
}
}
record = journal.retry_native(&record.binding.id)?;
}
anyhow::ensure!((!record.native_retired || matches!(action,"status"|"cancel"|"download")) && (!record.native_retired || !params.external_exposure),"This native invoice was retired before changing payment method; recover the replacement purchase");
if action == "pay" {
anyhow::ensure!(
params.operation_id.is_some(),
"Original invoice operation required for native payment"
);
return crate::content_lightning::drive_native(
&self.config.data_dir,
journal,
&record.binding.id,
&super::lnd::external_invoice::NativeNode(self),
)
.await;
}
record.external_exposure |= params.external_exposure;
journal.save_buyer(&record)?;
drop(journal);
// Native-only local FAILED never cancels an externally exposed invoice.
// The seller terminal state is authoritative regardless of UI receipt loss.
let operation = Operation {
binding: record.binding.clone(),
action: if action == "retry" {
"create".into()
} else {
action.into()
},
};
let response = crate::fips::dial::PeerRequest::new(Some(&fips), &params.onion, ROUTE)
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(if action == "download" {
900
} else {
45
}))
.send_content_json(&self.config.data_dir, &peer.did, &operation)
.await;
let mut response = match response {
Ok((r, _)) => r,
Err(_) => {
return Ok(
serde_json::json!({"state":"unknown","operation_id":record.binding.id,"recovery_required":true,"error":"The original invoice request is saved on this node. Recover it; no replacement invoice was requested."}),
)
}
};
anyhow::ensure!(
response.status().is_success(),
"Seller could not resolve original invoice; recover operation {}",
record.binding.id
);
let journal = Journal::open(&self.config.data_dir).await?;
if action == "download" {
let mut paid = record
.last
.clone()
.context("Original invoice metadata missing; recover it first")?;
let source = paid
.source
.clone()
.context("Original invoice snapshot missing")?;
anyhow::ensure!(
response.content_length() == Some(source.size),
"Original invoice file length changed"
);
paid.state = Phase::Settled;
paid.can_switch_method = false;
record.last = Some(paid);
journal.save_buyer(&record)?;
drop(journal);
let stream = crate::content_purchase_download::verified_stream(
response.bytes_stream(),
source.sha256,
source.size,
);
let owned = crate::content_owned::record_purchase_stream(
&self.config.data_dir,
crate::content_owned::OwnedItem {
onion: params.onion,
content_id: params.content_id,
filename: source.filename,
mime_type: source.mime_type,
size_bytes: source.size,
paid_sats: record.binding.price_sats,
ecash_backend: "lightning".into(),
purchased_at: chrono::Utc::now().to_rfc3339(),
download_complete: false,
},
Box::pin(stream),
Some(source.size),
)
.await?;
return Ok(
serde_json::json!({"owned":true,"owned_content_id":owned.content_id,"mime_type":owned.mime_type}),
);
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
bytes.len() + chunk.len() <= 16384,
"Invoice response too large"
);
bytes.extend_from_slice(&chunk)
}
let status: Status = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
status.binding == record.binding
&& status.source.is_some()
&& status.payment_hash.len() == 64
&& status.payment_hash.bytes().all(|b| b.is_ascii_hexdigit())
&& status.can_switch_method == (status.state == Phase::CanceledUnpaid),
"Seller invoice binding changed"
);
if let Some(bolt11) = &status.bolt11 {
let invoice: lightning_invoice::Bolt11Invoice =
bolt11.parse().context("Seller invoice is invalid")?;
invoice.check_signature()?;
anyhow::ensure!(
invoice.payment_hash().to_string() == status.payment_hash
&& invoice.amount_milli_satoshis()
== record.binding.price_sats.checked_mul(1000),
"Invoice hash or amount differs from saved purchase"
);
}
if let Some(previous) = &record.last {
anyhow::ensure!(
previous.payment_hash == status.payment_hash
&& previous.source == status.source
&& previous
.bolt11
.as_ref()
.is_none_or(|v| status.bolt11.as_ref() == Some(v)),
"Original invoice replaced"
);
}
record.last = Some(status.clone());
journal.save_buyer(&record)?;
Ok(
serde_json::json!({"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"payment_hash":status.payment_hash,"bolt11":if record.external_exposure{status.bolt11}else{None},"state":match status.state{Phase::Settled=>"settled",Phase::CanceledUnpaid=>"canceled",Phase::Issued=>"open",Phase::Prepared=>"prepared",Phase::Dispatched=>"unknown",Phase::CancelRequested=>"cancel_requested"},"paid":status.state==Phase::Settled,"can_switch_method":status.can_switch_method,"cancel_supported":true,"external_exposure":record.external_exposure}),
)
}
}
impl RpcHandler {
/// Caller holds content_payment_admission before entering any rail journal.
pub(super) async fn ensure_invoice_allows_other_rail(
&self,
buyer: &str,
seller: &str,
content: &str,
) -> Result<()> {
let journal = Journal::open(&self.config.data_dir).await?;
if let Some(mut record) = journal.buyer_for(buyer, seller, content)? {
anyhow::ensure!(record.native_replacement.is_none(),
"An explicit native retry is being recovered; recover its replacement operation first");
anyhow::ensure!(
!record.external_exposure,
"An externally payable invoice remains unresolved; cancel or recover it first"
);
let status = record
.last
.clone()
.context("Original invoice creation is unresolved; recover it first")?;
anyhow::ensure!(
status.state != Phase::Settled,
"Original Lightning purchase is paid; recover its file"
);
// A local terminal failure can release only a never-exposed native
// attempt. This check runs under the same outer lock as QR exposure.
anyhow::ensure!(
record.native_dispatched,
"Original invoice has not been canceled; cancel it before replacing the method"
);
if record.native_result.as_deref() != Some("failed") {
let payment = self
.handle_lnd_paymentstatus(Some(
serde_json::json!({"payment_hash":status.payment_hash}),
))
.await?;
anyhow::ensure!(
payment["status"] == "failed",
"Original native Lightning attempt remains unresolved"
);
}
record.native_result = Some("failed".into());
record.native_retired = true;
journal.save_buyer(&record)?;
}
Ok(())
}
}
+108 -26
View File
@@ -272,28 +272,8 @@ impl RpcHandler {
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
// Fee control: either a confirmation target or an explicit fee rate // Omitted fees target the next block; explicit slower/custom choices win.
let target_conf = params.get("target_conf").and_then(|v| v.as_i64()); let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(&params)?;
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
if target_conf.is_some() && sat_per_vbyte.is_some() {
return Err(anyhow::anyhow!(
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
));
}
if let Some(tc) = target_conf {
if !(1..=1008).contains(&tc) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
}
if let Some(rate) = sat_per_vbyte {
if !(1..=5000).contains(&rate) {
return Err(anyhow::anyhow!(
"Invalid sat_per_vbyte: must be between 1 and 5000"
));
}
}
info!( info!(
peer = pubkey, peer = pubkey,
@@ -473,6 +453,7 @@ impl RpcHandler {
)); ));
} }
let fee_query = close_channel_fee_query(&params)?;
let force = params let force = params
.get("force") .get("force")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
@@ -498,13 +479,11 @@ impl RpcHandler {
.build() .build()
.context("Failed to create streaming HTTP client")?; .context("Failed to create streaming HTTP client")?;
let url = format!( let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]);
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
parts[0], parts[1], force
);
let mut resp = client let mut resp = client
.delete(&url) .delete(&url)
.query(&fee_query)
.header("Grpc-Metadata-macaroon", &macaroon_hex) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await
@@ -572,3 +551,106 @@ impl RpcHandler {
} }
} }
} }
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
/// With neither parameter LND uses a lax target; keep legacy clients on our
/// explicit next-block target rather than silently accepting that default.
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
let force = match params.get("force") {
None | Some(serde_json::Value::Null) => false,
Some(value) => value
.as_bool()
.ok_or_else(|| anyhow::anyhow!("force must be a boolean"))?,
};
let integer = |key: &str, max: u64| -> Result<Option<u64>> {
match params.get(key) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(value) => {
let n = value
.as_u64()
.ok_or_else(|| anyhow::anyhow!("{key} must be a positive whole number"))?;
anyhow::ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
Ok(Some(n))
}
}
};
let target = integer("target_conf", 1008)?;
let rate = integer("sat_per_vbyte", 5000)?;
anyhow::ensure!(
target.is_none() || rate.is_none(),
"Specify either target_conf or sat_per_vbyte, not both"
);
anyhow::ensure!(
!force || (target.is_none() && rate.is_none()),
"Closing fee selection requires a cooperative close"
);
let mut query = vec![("force", force.to_string())];
if !force {
if let Some(rate) = rate {
query.push(("sat_per_vbyte", rate.to_string()));
} else {
query.push((
"target_conf",
target
.unwrap_or(super::fee_policy::DEFAULT_TARGET as u64)
.to_string(),
));
}
}
Ok(query)
}
#[cfg(test)]
mod close_fee_tests {
use super::*;
#[test]
fn close_fee_query_forwards_presets_custom_and_legacy_default() {
for target in [1, 3, 6, 1008] {
assert_eq!(
close_channel_fee_query(&serde_json::json!({"target_conf":target})).unwrap(),
vec![
("force", "false".into()),
("target_conf", target.to_string())
]
);
}
for rate in [1, 25, 5000] {
let query =
close_channel_fee_query(&serde_json::json!({"sat_per_vbyte":rate})).unwrap();
let request = reqwest::Client::new()
.delete("http://localhost/v1/channels/test/0")
.query(&query)
.build()
.unwrap();
assert_eq!(request.method(), reqwest::Method::DELETE);
assert_eq!(
request.url().query(),
Some(format!("force=false&sat_per_vbyte={rate}").as_str())
);
}
assert_eq!(
close_channel_fee_query(&serde_json::json!({})).unwrap(),
vec![("force", "false".into()), ("target_conf", "1".into())]
);
assert_eq!(
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
vec![("force", "true".into())]
);
}
#[test]
fn malformed_or_conflicting_close_fees_fail_before_wallet_access() {
for params in [
serde_json::json!({"target_conf":1,"sat_per_vbyte":2}),
serde_json::json!({"force":true,"target_conf":1}),
serde_json::json!({"force":"false"}),
serde_json::json!({"target_conf":0}),
serde_json::json!({"target_conf":1009}),
serde_json::json!({"sat_per_vbyte":5001}),
serde_json::json!({"sat_per_vbyte":-1}),
serde_json::json!({"sat_per_vbyte":1.5}),
serde_json::json!({"sat_per_vbyte":"25"}),
] {
assert!(close_channel_fee_query(&params).is_err(), "{params}");
}
}
}
@@ -0,0 +1,201 @@
use super::LND_REST_BASE_URL;
use crate::{
api::rpc::RpcHandler,
content_lightning::{Binding, Invoice, InvoiceNode, Journal, Status},
};
use anyhow::{Context, Result};
use base64::Engine;
struct Node {
client: reqwest::Client,
macaroon: String,
}
fn number(v: &serde_json::Value) -> Option<u64> {
v.as_u64().or_else(|| v.as_str()?.parse().ok())
}
impl InvoiceNode for Node {
async fn prepare_creation(&self) -> Result<()> {
let info: serde_json::Value = self
.client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &self.macaroon)
.send()
.await?
.error_for_status()?
.json()
.await?;
anyhow::ensure!(
info["identity_pubkey"]
.as_str()
.is_some_and(|key| !key.is_empty()),
"LND invoice service is not ready; original preparation retained"
);
Ok(())
}
async fn lookup(&self, hash: &str) -> Result<Option<Invoice>> {
let response = self
.client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
.header("Grpc-Metadata-macaroon", &self.macaroon)
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let body: serde_json::Value = response.error_for_status()?.json().await?;
let raw = body["r_hash"].as_str().context("Invoice hash omitted")?;
let payment_hash = hex::encode(base64::engine::general_purpose::STANDARD.decode(raw)?);
Ok(Some(Invoice {
payment_hash,
bolt11: body["payment_request"]
.as_str()
.context("Invoice payment request omitted")?
.into(),
price_sats: number(&body["value"]).context("Invoice amount omitted")?,
state: body["state"]
.as_str()
.context("Invoice state omitted")?
.into(),
paid_sats: number(&body["amt_paid_sat"]),
paid_msats: number(&body["amt_paid_msat"]),
}))
}
async fn add(&self, binding: &Binding, preimage_hex: &str) -> Result<()> {
let preimage = base64::engine::general_purpose::STANDARD.encode(hex::decode(preimage_hex)?);
self.client.post(format!("{LND_REST_BASE_URL}/v1/invoices")).header("Grpc-Metadata-macaroon",&self.macaroon)
.json(&serde_json::json!({"memo":format!("Archipelago peer file {}",binding.content_id),"value":binding.price_sats.to_string(),"r_preimage":preimage,"private":true,"expiry":"3600"})).send().await?.error_for_status()?;
Ok(())
}
async fn cancel(&self, hash: &str) -> Result<()> {
self.client.post(format!("{LND_REST_BASE_URL}/v2/invoices/cancel")).header("Grpc-Metadata-macaroon",&self.macaroon)
.json(&serde_json::json!({"payment_hash":base64::engine::general_purpose::STANDARD.encode(hex::decode(hash)?)})).send().await?.error_for_status()?;
Ok(())
}
}
impl RpcHandler {
pub(crate) async fn drive_external_invoice(
&self,
journal: &Journal,
binding: &Binding,
cancel: bool,
) -> Result<Status> {
// Configuration/auth preflight before the engine persists dispatch.
let (client, macaroon) = self.lnd_client().await?;
crate::content_lightning::drive(journal, binding, &Node { client, macaroon }, cancel).await
}
}
/// Prepared before the durable native-dispatch marker. Once execute is called,
/// every transport error is ambiguous and only original-hash lookup may follow.
pub(crate) struct PreparedNativePayment {
client: reqwest::Client,
request: reqwest::Request,
hash: String,
amount: u64,
}
impl PreparedNativePayment {
pub(crate) async fn execute(self) -> Result<serde_json::Value> {
let response = self
.client
.execute(self.request)
.await
.context("Native payment response is unknown; recover the original operation")?;
let status = response.status();
let body: serde_json::Value = response
.json()
.await
.context("Native payment response is unknown")?;
anyhow::ensure!(
status.is_success(),
"LND did not confirm the original payment; recover its status"
);
let payment = body.get("result").unwrap_or(&body);
anyhow::ensure!(
payment
.get("payment_hash")
.and_then(|v| v.as_str())
.is_none_or(|hash| hash == self.hash),
"LND payment hash changed"
);
Ok(super::payments::router_payment_outcome(
payment,
&self.hash,
self.amount as i64,
))
}
}
impl RpcHandler {
pub(crate) async fn prepare_bound_invoice_payment(
&self,
bolt11: &str,
hash: &str,
amount: u64,
) -> Result<PreparedNativePayment> {
let invoice: lightning_invoice::Bolt11Invoice =
bolt11.parse().context("Invalid original invoice")?;
invoice.check_signature()?;
anyhow::ensure!(
invoice.payment_hash().to_string() == hash
&& invoice.amount_milli_satoshis() == amount.checked_mul(1000),
"Original invoice amount/hash changed"
);
anyhow::ensure!(
!invoice.is_expired(),
"Original invoice expired; cancel or recover it before choosing another method"
);
let (client, macaroon) = self.lnd_client().await?;
let info: serde_json::Value = client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon)
.send()
.await?
.error_for_status()?
.json()
.await?;
let network = match invoice.currency() {
lightning_invoice::Currency::Bitcoin => "mainnet",
lightning_invoice::Currency::BitcoinTestnet => "testnet",
lightning_invoice::Currency::Regtest => "regtest",
lightning_invoice::Currency::Signet => "signet",
lightning_invoice::Currency::Simnet => "simnet",
};
anyhow::ensure!(
info["chains"].as_array().is_some_and(|chains| chains
.iter()
.any(|chain| chain["chain"] == "bitcoin" && chain["network"] == network)),
"Original invoice belongs to another Bitcoin network"
);
let client = reqwest::Client::builder()
.no_proxy()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(8))
.danger_accept_invalid_certs(true)
.build()?;
let request=client.post(format!("{LND_REST_BASE_URL}/v2/router/send")).header("Grpc-Metadata-macaroon",macaroon).json(&serde_json::json!({"payment_request":bolt11,"no_inflight_updates":true,"timeout_seconds":120,"fee_limit_sat":amount})).build()?;
Ok(PreparedNativePayment {
client,
request,
hash: hash.into(),
amount,
})
}
}
impl crate::content_lightning::PreparedPayment for PreparedNativePayment {
async fn execute(self) -> Result<serde_json::Value> {
PreparedNativePayment::execute(self).await
}
}
pub(crate) struct NativeNode<'a>(pub &'a RpcHandler);
impl crate::content_lightning::NativeInvoiceNode for NativeNode<'_> {
type Prepared = PreparedNativePayment;
async fn prepare(&self, invoice: &str, hash: &str, amount: u64) -> Result<Self::Prepared> {
self.0
.prepare_bound_invoice_payment(invoice, hash, amount)
.await
}
async fn lookup_payment(&self, hash: &str) -> Result<serde_json::Value> {
self.0
.handle_lnd_paymentstatus(Some(serde_json::json!({"payment_hash":hash})))
.await
}
}
@@ -0,0 +1,921 @@
//! WalletKit BumpFee is CPFP for new wallet outputs, RBF only for sweeper inputs.
//! Never feed an ordinary payment input to it and call that a replacement.
use super::LND_REST_BASE_URL;
use crate::api::rpc::RpcHandler;
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{collections::HashMap, path::Path, sync::LazyLock};
use tokio::{io::AsyncWriteExt, sync::Mutex};
static QUOTES: LazyLock<Mutex<HashMap<String, Quote>>> = LazyLock::new(Default::default);
// Serialize check/register/persist across dashboard clients. The create_new receipt
// additionally survives process restarts and prevents retries of ambiguous results.
static SUBMIT: Mutex<()> = Mutex::const_new(());
const QUOTE_SECONDS: u64 = 60;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
struct Plan {
txid: String,
method: String,
input_txid: String,
input_index: u32,
parent_txid: String,
recipient_sats: u64,
rate_sat_vb: u64,
current_fee_sats: u64,
additional_fee_sats: u64,
total_fee_sats: u64,
budget_sats: u64,
input_sats: u64,
parent_vsize: u64,
sweep_vsize_bound: u64,
tip: String,
}
#[derive(Clone, Serialize, Deserialize)]
struct Quote {
quote_id: String,
expires_at: u64,
custom_rate: Option<u64>,
#[serde(flatten)]
plan: Plan,
}
#[derive(Serialize, Deserialize)]
struct Operation {
quote: Quote,
status: String,
message: String,
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn number(v: &Value) -> Result<u64> {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.context("Missing or invalid wallet amount")
}
fn txid_param(p: &Value) -> Result<String> {
let s = p["txid"].as_str().context("Missing transaction ID")?;
ensure!(
s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid transaction ID"
);
Ok(s.to_ascii_lowercase())
}
fn btc_sats(v: &Value) -> Result<u64> {
let n = v.as_f64().context("Missing Bitcoin fee")? * 100_000_000.0;
ensure!(
n.is_finite() && n >= 0.0 && n <= 2_100_000_000_000_000.0,
"Invalid Bitcoin fee"
);
Ok(n.round() as u64)
}
fn outpoint_matches(v: &Value, txid: &str, index: u32) -> bool {
v["txid_str"].as_str() == Some(txid) && v["output_index"].as_u64() == Some(index as u64)
}
fn array<'a>(v: &'a Value, key: &str) -> Result<&'a Vec<Value>> {
v[key]
.as_array()
.with_context(|| format!("Missing wallet field: {key}"))
}
fn sweep_size(output: &Value) -> Result<u64> {
// One native input, one wallet taproot output, including signature rounding.
match output["output_type"].as_str() {
Some("SCRIPT_TYPE_WITNESS_V1_TAPROOT") => Ok(112),
Some("SCRIPT_TYPE_WITNESS_V0_PUBKEY_HASH") => Ok(123),
_ => bail!("This output type is not supported for fee bumping yet"),
}
}
fn fee_budget(
rate: u64,
parent_size: u64,
parent_fee: u64,
size: u64,
old_fee: u64,
relay: u64,
input: u64,
) -> Result<u64> {
ensure!(
(1..=5000).contains(&rate),
"Fee rate must be a whole number from 1 to 5000 sat/vB"
);
ensure!(
parent_size <= 100_000 && size <= 100_000 && relay <= 5000,
"Unsupported package size or relay fee"
);
let required = rate * (parent_size + size);
let mut budget = required.saturating_sub(parent_fee).max(relay * size);
if old_fee > 0 {
budget = budget.max(old_fee + relay * size + 1);
}
ensure!(budget > old_fee, "Choose a higher fee rate");
// Conservative dust buffer; never attach unrelated wallet inputs to fund fees.
ensure!(
budget.checked_add(1000).is_some_and(|v| v <= input),
"Not enough wallet change for this fee; choose a lower rate"
);
Ok(budget)
}
/// Find the highest rate that fits the already selected wallet output. This is
/// only a quote-time calculation: it never asks LND to reserve or spend the
/// output. Keeping it here lets the caller give an actionable answer when the
/// requested target is too expensive.
fn highest_affordable_rate(
requested: u64,
parent_size: u64,
parent_fee: u64,
size: u64,
old_fee: u64,
relay: u64,
input: u64,
) -> Option<u64> {
if requested <= 1 {
return None;
}
let mut low = 1;
let mut high = requested.saturating_sub(1);
let mut best = None;
while low <= high {
let mid = low + (high - low) / 2;
if fee_budget(mid, parent_size, parent_fee, size, old_fee, relay, input).is_ok() {
best = Some(mid);
low = mid.saturating_add(1);
} else {
high = mid.saturating_sub(1);
}
}
best
}
fn quote_budget_error(
requested: u64,
parent_size: u64,
parent_fee: u64,
size: u64,
old_fee: u64,
relay: u64,
input: u64,
) -> anyhow::Error {
let available = input.saturating_sub(1000);
let suggestion = highest_affordable_rate(
requested,
parent_size,
parent_fee,
size,
old_fee,
relay,
input,
)
.map(|rate| format!(" Try {rate} sat/vB or lower."))
.unwrap_or_else(|| " No fee rate can currently fit this output.".into());
anyhow::anyhow!(
"Not enough wallet change for {requested} sat/vB: at most {available} sats is spendable for this bump.{suggestion}"
)
}
async fn lnd(
client: &reqwest::Client,
macaroon: &str,
path: &str,
body: Option<Value>,
) -> Result<Value> {
let url = format!("{LND_REST_BASE_URL}{path}");
let req = match body {
Some(v) => client.post(url).json(&v),
None => client.get(url),
};
let response = req
.header("Grpc-Metadata-macaroon", macaroon)
.send()
.await?;
let status = response.status();
let value: Value = response.json().await.context("Invalid LND response")?;
ensure!(
status.is_success() && value.get("code").is_none(),
"{}",
value["message"].as_str().unwrap_or("LND request failed")
);
Ok(value)
}
fn validate_quote(quote: &Quote, fresh: &Plan, timestamp: u64) -> Result<()> {
ensure!(
quote.expires_at > timestamp && quote.plan == *fresh,
"Transaction or fees changed; review a fresh quote"
);
Ok(())
}
fn bump_body(plan: &Plan) -> Value {
json!({"outpoint":{"txid_str":plan.input_txid,"output_index":plan.input_index},
"sat_per_vbyte":plan.rate_sat_vb.to_string(), "budget":plan.budget_sats.to_string(),
"deadline_delta":1, "immediate":true})
}
async fn reserve(path: &Path, op: &Operation) -> Result<()> {
let parent = path.parent().context("Invalid operation path")?;
tokio::fs::create_dir_all(parent).await?;
let mut f = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.await
.context("A bump already exists for this transaction; check its status")?;
f.write_all(&serde_json::to_vec(op)?).await?;
f.sync_all().await?;
// Sync directory entry too: a crash must not make a submitted operation vanish.
tokio::fs::File::open(parent).await?.sync_all().await?;
Ok(())
}
// Only a recorded Archy CPFP with one owned input and no external outputs may
// be folded into a payment. Labels and a fee-sized delta alone are not evidence.
fn fee_child_matches(tx: &Value, plan: &Plan) -> bool {
let input = format!("{}:{}", plan.input_txid, plan.input_index);
let amount = tx["amount"]
.as_i64()
.or_else(|| tx["amount"].as_str()?.parse().ok());
let fee = number(&tx["total_fees"])
.ok()
.and_then(|n| i64::try_from(n).ok());
tx["tx_hash"]
.as_str()
.is_some_and(|id| id.len() == 64 && id.bytes().all(|c| c.is_ascii_hexdigit()))
&& amount
.zip(fee)
.is_some_and(|(amount, fee)| fee > 0 && amount == -fee)
&& tx["previous_outpoints"].as_array().is_some_and(|inputs| {
inputs.len() == 1
&& inputs[0]["outpoint"] == input
&& inputs[0]["is_our_output"] == true
})
&& tx["output_details"].as_array().is_some_and(|outputs| {
!outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true)
})
}
impl RpcHandler {
pub(super) async fn group_fee_bump_history(
&self,
raw: &[Value],
normalized: &mut Vec<Value>,
client: &reqwest::Client,
) {
let mut hidden = std::collections::HashSet::new();
for parent in normalized.iter_mut() {
if parent["direction"] != "outgoing" {
continue;
}
let Some(id) = parent["tx_hash"].as_str().map(str::to_owned) else {
continue;
};
if id.len() != 64 || !id.bytes().all(|c| c.is_ascii_hexdigit()) {
continue;
}
let path = self
.config
.data_dir
.join("wallet/fee-bumps")
.join(format!("{id}.json"));
let Ok(bytes) = tokio::fs::read(path).await else {
continue;
};
let Ok(op) = serde_json::from_slice::<Operation>(&bytes) else {
continue;
};
let plan = &op.quote.plan;
if plan.method != "cpfp"
|| plan.txid != id
|| plan.parent_txid != id
|| plan.input_txid != id
{
continue;
}
let candidates: Vec<_> = raw
.iter()
.filter(|tx| fee_child_matches(tx, plan))
.collect();
let mut active = Vec::new();
for child in &candidates {
let child_id = child["tx_hash"].as_str().unwrap();
if child["num_confirmations"].as_i64().unwrap_or(0) > 0
|| self
.bitcoin_rpc_call::<Value>(client, "getmempoolentry", &[json!(child_id)])
.await
.is_ok()
{
active.push(*child);
}
}
// Ambiguous or unavailable chain state must not hide wallet history.
if active.len() != 1 {
continue;
}
let current = active[0];
parent["bump_fee_sats"] = json!(number(&current["total_fees"]).unwrap());
parent["fee_bump_txid"] = current["tx_hash"].clone();
parent["fee_bump_confirmations"] = current["num_confirmations"].clone();
parent["fee_bump_history"] = json!(candidates.iter().map(|child| {
let child_id = child["tx_hash"].as_str().unwrap();
hidden.insert(child_id.to_owned());
json!({"tx_hash":child_id,"fee_sats":number(&child["total_fees"]).unwrap(),
"status":if child["tx_hash"] != current["tx_hash"] { "replaced" }
else if child["num_confirmations"].as_i64().unwrap_or(0) > 0 { "confirmed" } else { "mempool" }})
}).collect::<Vec<_>>());
}
normalized.retain(|tx| !tx["tx_hash"].as_str().is_some_and(|id| hidden.contains(id)));
}
async fn bump_plan(&self, txid: &str, custom_rate: Option<u64>) -> Result<Plan> {
let (client, macaroon) = self.lnd_client().await?;
let info = lnd(&client, &macaroon, "/v1/getinfo", None).await?;
ensure!(
info["synced_to_chain"] == true,
"Wait for the wallet to finish syncing"
);
let version = info["version"]
.as_str()
.context("LND version is unavailable")?;
let mut parts = version.trim_start_matches('v').split('.');
let major: u32 = parts
.next()
.unwrap_or("")
.parse()
.context("Invalid LND version")?;
let minor: u32 = parts
.next()
.unwrap_or("")
.parse()
.context("Invalid LND version")?;
ensure!(
major > 0 || minor >= 21,
"This fee-bump interface requires LND 0.21 or newer"
);
let history = lnd(&client, &macaroon, "/v1/transactions", None).await?;
let txs = array(&history, "transactions")?;
let tx = txs
.iter()
.find(|t| t["tx_hash"] == txid)
.context("Transaction is not in this wallet")?;
ensure!(
tx["num_confirmations"].as_i64() == Some(0),
"This transaction is no longer pending"
);
let entry: Value = self
.bitcoin_rpc_call(&client, "getmempoolentry", &[json!(txid)])
.await
.context("Transaction is not currently in the node's mempool")?;
ensure!(
number(&entry["descendantcount"])? == 1,
"This transaction already has a child; open the child's Bump options instead"
);
let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?;
let sweeps = array(&pending, "pending_sweeps")?;
let published = lnd(
&client,
&macaroon,
"/v2/wallet/sweeps?verbose=false&start_height=-1",
None,
)
.await?;
let is_sweep = published["transaction_ids"]["transaction_ids"]
.as_array()
.is_some_and(|ids| ids.iter().any(|id| id == txid));
let outputs = array(tx, "output_details")?;
ensure!(
tx["amount"]
.as_str()
.and_then(|v| v.parse::<i64>().ok())
.or_else(|| tx["amount"].as_i64())
.is_some_and(|v| v < 0),
"Bump is available for outgoing payments and wallet fee sweeps"
);
let (
method,
input_txid,
input_index,
input_sats,
parent_txid,
parent_size,
parent_fee,
old_fee,
size,
recipient_sats,
) = if is_sweep {
// Only a simple wallet CPFP sweep is replaceable here. Anchor/HTLC,
// batched sweeps and arbitrary signed payments need different previews.
let raw: Value = self
.bitcoin_rpc_call(&client, "getrawtransaction", &[json!(txid), json!(true)])
.await?;
let inputs = array(&raw, "vin")?;
ensure!(
inputs.len() == 1 && outputs.len() == 1 && outputs[0]["is_our_address"] == true,
"RBF for batched or channel sweeps is not supported here yet"
);
let input_txid = inputs[0]["txid"]
.as_str()
.context("Missing sweep input")?
.to_string();
let index = u32::try_from(number(&inputs[0]["vout"])?)?;
ensure!(
sweeps.len() == 1 && outpoint_matches(&sweeps[0]["outpoint"], &input_txid, index),
"RBF is unavailable while other wallet sweeps are active"
);
let parent = txs
.iter()
.find(|t| t["tx_hash"] == input_txid)
.context("Sweep parent is unavailable")?;
let parent_output = array(parent, "output_details")?
.iter()
.find(|o| {
number(&o["output_index"]).ok() == Some(index as u64)
&& o["is_our_address"] == true
})
.context("RBF requires a wallet-owned change input")?;
let parent_entry: Value = self
.bitcoin_rpc_call(&client, "getmempoolentry", &[json!(input_txid)])
.await
.context("Only unconfirmed CPFP sweep replacements are supported here")?;
ensure!(
number(&parent_entry["ancestorcount"])? == 1
&& number(&parent_entry["descendantcount"])? == 2,
"Complex sweep package cannot be quoted safely"
);
let recipients = recipient_amount(parent)?;
(
"rbf",
input_txid.clone(),
index,
number(&parent_output["amount"])?,
input_txid,
number(&parent_entry["vsize"])?,
btc_sats(&parent_entry["fees"]["base"])?,
btc_sats(&entry["fees"]["base"])?,
sweep_size(parent_output)?.max(number(&entry["vsize"])?),
recipients,
)
} else {
ensure!(
sweeps.is_empty(),
"Another wallet sweep is active; wait for it before creating a CPFP bump"
);
ensure!(
number(&entry["ancestorcount"])? == 1,
"Fee bumping a chain of unconfirmed payments is not supported yet"
);
let unspent = lnd(
&client,
&macaroon,
"/v2/wallet/utxos",
Some(json!({"unconfirmed_only":true})),
)
.await?;
let utxos = array(&unspent, "utxos")?;
let leases = lnd(
&client,
&macaroon,
"/v2/wallet/utxos/leases",
Some(json!({})),
)
.await?;
let locked = array(&leases, "locked_utxos")?;
let output = outputs
.iter()
.filter(|o| o["is_our_address"] == true && sweep_size(o).is_ok())
.filter(|o| {
number(&o["output_index"]).ok().is_some_and(|i| {
utxos
.iter()
.any(|u| outpoint_matches(&u["outpoint"], txid, i as u32))
&& !locked
.iter()
.any(|u| outpoint_matches(&u["outpoint"], txid, i as u32))
})
})
.max_by_key(|o| number(&o["amount"]).unwrap_or(0))
.context(
"RBF is unavailable for this payment. CPFP needs spendable wallet-owned change",
)?;
let index = u32::try_from(number(&output["output_index"])?)?;
let available: Value = self
.bitcoin_rpc_call(
&client,
"gettxout",
&[json!(txid), json!(index), json!(true)],
)
.await?;
ensure!(
available.is_object()
&& number(&available["confirmations"])? == 0
&& btc_sats(&available["value"])? == number(&output["amount"])?,
"Change is no longer available"
);
(
"cpfp",
txid.to_string(),
index,
number(&output["amount"])?,
txid.to_string(),
number(&entry["vsize"])?,
btc_sats(&entry["fees"]["base"])?,
0,
sweep_size(output)?,
recipient_amount(tx)?,
)
};
let mempool: Value = self
.bitcoin_rpc_call(&client, "getmempoolinfo", &[])
.await?;
let relay = btc_sats(&mempool["incrementalrelayfee"])?
.div_ceil(1000)
.max(1);
let floor = btc_sats(&mempool["mempoolminfee"])?
.max(btc_sats(&mempool["minrelaytxfee"])?)
.div_ceil(1000)
.max(1);
let rate = match custom_rate {
Some(rate) => {
ensure!(
rate >= floor,
"Custom rate is below the current mempool minimum"
);
rate
}
None => {
let estimate = lnd(&client, &macaroon, "/v2/wallet/estimatefee/1", None).await?;
number(&estimate["sat_per_kw"])?.div_ceil(250).max(floor)
}
};
let budget = fee_budget(
rate,
parent_size,
parent_fee,
size,
old_fee,
relay.max(floor),
input_sats,
)
.map_err(|error| {
if error.to_string().contains("Not enough wallet change") {
quote_budget_error(
rate,
parent_size,
parent_fee,
size,
old_fee,
relay.max(floor),
input_sats,
)
} else {
error
}
})?;
let tip: String = self
.bitcoin_rpc_call(&client, "getbestblockhash", &[])
.await?;
Ok(Plan {
txid: txid.to_string(),
method: method.into(),
input_txid,
input_index,
parent_txid,
recipient_sats,
rate_sat_vb: rate,
current_fee_sats: parent_fee + old_fee,
additional_fee_sats: budget - old_fee,
total_fee_sats: parent_fee + budget,
budget_sats: budget,
input_sats,
parent_vsize: parent_size,
sweep_vsize_bound: size,
tip,
})
}
pub(in crate::api::rpc) async fn handle_lnd_bump_quote(
&self,
params: Option<Value>,
) -> Result<Value> {
let p = params.unwrap_or_default();
let txid = txid_param(&p)?;
let path = self
.config
.data_dir
.join("wallet/fee-bumps")
.join(format!("{txid}.json"));
ensure!(
!path.try_exists()?,
"A bump was already submitted for this transaction. Check its status"
);
let custom = p
.get("sat_per_vbyte")
.map(|v| v.as_u64().context("Custom rate must be a whole number"))
.transpose()?;
if let Some(rate) = custom {
ensure!(
(1..=5000).contains(&rate),
"Custom rate must be 1–5000 sat/vB"
);
}
let plan = self.bump_plan(&txid, custom).await?;
let quote = Quote {
quote_id: uuid::Uuid::new_v4().to_string(),
expires_at: now() + QUOTE_SECONDS,
custom_rate: custom,
plan,
};
let mut quotes = QUOTES.lock().await;
quotes.retain(|_, q| q.expires_at > now());
ensure!(quotes.len() < 128, "Too many fee quotes; try again shortly");
quotes.insert(quote.quote_id.clone(), quote.clone());
Ok(serde_json::to_value(quote)?)
}
pub(in crate::api::rpc) async fn handle_lnd_bump_submit(
&self,
params: Option<Value>,
) -> Result<Value> {
let p = params.unwrap_or_default();
let txid = txid_param(&p)?;
let _guard = SUBMIT.lock().await;
let path = self
.config
.data_dir
.join("wallet/fee-bumps")
.join(format!("{txid}.json"));
if path.try_exists()? {
return self
.handle_lnd_bump_status(Some(json!({"txid":txid})))
.await;
}
let id = p["quote_id"]
.as_str()
.context("A reviewed fee quote is required")?;
let quote = QUOTES
.lock()
.await
.get(id)
.cloned()
.context("Quote expired; review the fee again")?;
ensure!(
quote.plan.txid == txid && quote.expires_at > now(),
"Quote expired; review the fee again"
);
let fresh = self.bump_plan(&txid, quote.custom_rate).await?;
validate_quote(&quote, &fresh, now())?;
let op = Operation {
quote: quote.clone(),
status: "unknown".into(),
message: "Submission recorded; checking the wallet. Do not submit another bump.".into(),
};
reserve(&path, &op).await?;
QUOTES.lock().await.remove(id);
let (client, macaroon) = self.lnd_client().await?;
// At a one-block deadline LND may spend ALL this explicitly previewed
// budget. It is always below input value, so no extra funding is requested.
let result = lnd(
&client,
&macaroon,
"/v2/wallet/bumpfee",
Some(bump_body(&fresh)),
)
.await;
// Keep the write-ahead record even for an RPC error: a lost response can
// conceal an accepted bump. Status reconciles from wallet/mempool evidence.
match result {
Ok(_) => Ok(
json!({"status":"registered", "message":"Bump registered with the wallet. Waiting for broadcast.", "quote":quote}),
),
Err(_) => Ok(
json!({"status":"unknown", "message":"The wallet response was not confirmed. Check status; do not submit again.", "quote":quote}),
),
}
}
pub(in crate::api::rpc) async fn handle_lnd_bump_status(
&self,
params: Option<Value>,
) -> Result<Value> {
let txid = txid_param(&params.unwrap_or_default())?;
let path = self
.config
.data_dir
.join("wallet/fee-bumps")
.join(format!("{txid}.json"));
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(json!({"status":"none"}))
}
Err(e) => return Err(e.into()),
};
let op: Operation = serde_json::from_slice(&bytes)
.context("Bump receipt needs recovery; do not resubmit")?;
let (client, macaroon) = self.lnd_client().await?;
let history = lnd(&client, &macaroon, "/v1/transactions", None).await?;
let plan = &op.quote.plan;
let input = format!("{}:{}", plan.input_txid, plan.input_index);
let mut candidates: Vec<&Value> = array(&history, "transactions")?
.iter()
.filter(|t| {
t["tx_hash"] != txid
&& t["output_details"].as_array().is_some_and(|outputs| {
!outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true)
})
&& t["previous_outpoints"]
.as_array()
.is_some_and(|inputs| inputs.iter().any(|i| i["outpoint"] == input))
})
.collect();
candidates.sort_by_key(|t| std::cmp::Reverse(number(&t["time_stamp"]).unwrap_or(0)));
for t in candidates {
let id = t["tx_hash"]
.as_str()
.context("Missing bump transaction ID")?;
let confirmed = t["num_confirmations"].as_i64().unwrap_or(0) > 0;
let accepted = if confirmed {
false
} else {
self.bitcoin_rpc_call::<Value>(&client, "getmempoolentry", &[json!(id)])
.await
.is_ok()
};
if confirmed || accepted {
return Ok(json!({"status":if confirmed {"confirmed"} else {"mempool"},
"message":if confirmed {"Fee bump confirmed."} else {"Fee bump accepted in the node's mempool; awaiting confirmation."},
"bump_txid":id,"confirmations":t["num_confirmations"],"actual_sweep_fee_sats":number(&t["total_fees"])?,"quote":op.quote}));
}
}
let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?;
let registered = array(&pending, "pending_sweeps")?.iter().any(|s| {
outpoint_matches(&s["outpoint"], &plan.input_txid, plan.input_index)
&& number(&s["budget"]).ok() == Some(plan.budget_sats)
&& number(&s["requested_sat_per_vbyte"]).ok() == Some(plan.rate_sat_vb)
});
Ok(
json!({"status":if registered {"registered"} else {"unknown"},
"message":if registered {"Bump registered; waiting for a verified broadcast."} else {"Submission outcome is unknown. Do not submit again; check wallet status."}, "quote":op.quote}),
)
}
}
fn recipient_amount(tx: &Value) -> Result<u64> {
array(tx, "output_details")?
.iter()
.filter(|o| o["is_our_address"] == false)
.try_fold(0u64, |sum, o| {
sum.checked_add(number(&o["amount"])?)
.context("Recipient amount overflow")
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_plan() -> Plan {
serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":161650,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap()
}
#[test]
fn history_requires_owned_simple_fee_only_child() {
let plan = sample_plan();
let tx = json!({"tx_hash":"b".repeat(64),"amount":"-200","total_fees":"200",
"previous_outpoints":[{"outpoint":"a:0","is_our_output":true}],
"output_details":[{"is_our_address":true}]});
assert!(fee_child_matches(&tx, &plan));
for bad in [
json!({"amount":"-201"}),
json!({"amount":"200"}),
json!({"total_fees":"0"}),
json!({"previous_outpoints":[{"outpoint":"a:1","is_our_output":true}]}),
json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":false}]}),
json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":true},{"outpoint":"c:0","is_our_output":true}]}),
json!({"output_details":[{"is_our_address":false}]}),
json!({"output_details":[]}),
json!({"tx_hash":"../../invalid"}),
] {
let mut changed = tx.clone();
for (key, value) in bad.as_object().unwrap() {
changed[key] = value.clone();
}
assert!(!fee_child_matches(&changed, &plan), "{bad}");
}
}
#[test]
fn stale_quotes_cannot_silently_change_approved_fee_or_transaction() {
let plan = sample_plan();
let q = Quote {
quote_id: "q".into(),
expires_at: 100,
custom_rate: None,
plan: plan.clone(),
};
assert!(validate_quote(&q, &plan, 99).is_ok());
assert!(validate_quote(&q, &plan, 100).is_err());
let mut changed = plan.clone();
changed.budget_sats += 1;
assert!(validate_quote(&q, &changed, 99).is_err());
changed = plan.clone();
changed.input_index += 1;
assert!(validate_quote(&q, &changed, 99).is_err());
changed = plan.clone();
changed.recipient_sats -= 1;
assert!(validate_quote(&q, &changed, 99).is_err());
changed = plan.clone();
changed.tip = "new block".into();
assert!(validate_quote(&q, &changed, 99).is_err());
}
#[test]
fn mutation_always_has_explicit_budget_and_does_not_send_a_second_payment() {
assert_eq!(
bump_body(&sample_plan()),
json!({"outpoint":{"txid_str":"a","output_index":0},"sat_per_vbyte":"3","budget":"618","deadline_delta":1,"immediate":true})
);
let mut rbf = sample_plan();
rbf.method = "rbf".into();
rbf.txid = "child".into();
// RBF uses the already-registered input, not the child's output.
assert_eq!(bump_body(&rbf)["outpoint"]["txid_str"], "a");
}
#[test]
fn outpoint_ownership_and_recipient_exclude_wallet_change() {
assert!(outpoint_matches(
&json!({"txid_str":"a","output_index":2}),
"a",
2
));
assert!(!outpoint_matches(
&json!({"txid_str":"b","output_index":2}),
"a",
2
));
assert!(!outpoint_matches(
&json!({"txid_str":"a","output_index":3}),
"a",
2
));
assert_eq!(recipient_amount(&json!({"output_details":[{"is_our_address":true,"amount":"21126"},{"is_our_address":false,"amount":"161650"}]})).unwrap(), 161650);
assert!(recipient_amount(
&json!({"output_details":[{"is_our_address":false,"amount":"bad"}]})
)
.is_err());
}
#[test]
fn cpfp_budget_covers_parent_and_preserves_change() {
assert_eq!(fee_budget(3, 142, 144, 112, 0, 1, 21126).unwrap(), 618);
assert!(fee_budget(5000, 142, 144, 112, 0, 1, 21126).is_err());
assert!(fee_budget(0, 142, 144, 112, 0, 1, 21126).is_err());
}
#[test]
fn unaffordable_quote_explains_spendable_change_and_viable_rate() {
let suggested = highest_affordable_rate(5000, 142, 144, 112, 0, 1, 21126);
assert_eq!(suggested, Some(79));
let error = quote_budget_error(5000, 142, 144, 112, 0, 1, 21126).to_string();
assert!(error.contains("at most 20126 sats is spendable"));
assert!(error.contains("Try 79 sat/vB or lower"));
}
#[test]
fn no_affordable_rate_is_reported_without_mutating_the_output() {
let error = quote_budget_error(10, 142, 144, 112, 9_000, 1, 10_000).to_string();
assert!(error.contains("at most 9000 sats is spendable"));
assert!(error.contains("No fee rate can currently fit this output"));
}
#[test]
fn rbf_pays_incremental_relay_cost_and_counts_only_extra_cost() {
let fee = fee_budget(3, 142, 144, 112, 650, 1, 21126).unwrap();
assert_eq!(fee, 763);
assert_eq!(fee - 650, 113);
}
#[test]
fn unsupported_outputs_and_malformed_ids_fail_closed() {
assert!(sweep_size(&json!({"output_type":"SCRIPT_TYPE_WITNESS_V0_SCRIPT_HASH"})).is_err());
assert!(txid_param(&json!({"txid":"../../file"})).is_err());
assert!(number(&json!(-1)).is_err());
assert!(btc_sats(&json!(-0.1)).is_err());
assert_eq!(btc_sats(&json!(0.00000650)).unwrap(), 650);
}
#[tokio::test]
async fn receipt_prevents_duplicate_submission_after_restart() {
let dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
let path = dir.join("receipt.json");
let plan: Plan = serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":1000,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap();
let op = Operation {
quote: Quote {
quote_id: "q".into(),
expires_at: now() + 60,
custom_rate: None,
plan,
},
status: "unknown".into(),
message: "pending".into(),
};
reserve(&path, &op).await.unwrap();
assert!(reserve(&path, &op).await.is_err());
let restored: Operation =
serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap();
assert_eq!(restored.quote.plan.budget_sats, 618);
tokio::fs::remove_dir_all(dir).await.unwrap();
}
}
@@ -0,0 +1,120 @@
//! Explicit on-chain fee choices retain priority; omitted choices target the next block.
use anyhow::{ensure, Context, Result};
use serde_json::Value;
pub(super) const DEFAULT_TARGET: i64 = 1;
pub(super) fn estimated_sat_per_vbyte(value: &Value) -> Result<u64> {
let per_kw = value["sat_per_kw"]
.as_u64()
.or_else(|| value["sat_per_kw"].as_str().and_then(|s| s.parse().ok()))
.context("Next-block fee estimate is unavailable")?;
let rate = per_kw.div_ceil(250);
ensure!(
(1..=5000).contains(&rate),
"Next-block fee estimate is outside supported bounds; choose an explicit fee"
);
Ok(rate)
}
pub(super) fn fee_options(params: &Value) -> Result<(Option<i64>, Option<i64>)> {
let integer = |key: &str, max: i64| -> Result<Option<i64>> {
match params.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => {
let n = value
.as_i64()
.with_context(|| format!("{key} must be a positive whole number"))?;
ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
Ok(Some(n))
}
}
};
let target = integer("target_conf", 1008)?;
let rate = integer("sat_per_vbyte", 5000)?;
ensure!(
target.is_none() || rate.is_none(),
"Specify either target_conf or sat_per_vbyte, not both"
);
Ok((
if rate.is_none() {
Some(target.unwrap_or(DEFAULT_TARGET))
} else {
None
},
rate,
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn estimates_round_up_and_missing_or_extreme_estimates_fail_closed() {
assert_eq!(
estimated_sat_per_vbyte(&json!({"sat_per_kw":"501"})).unwrap(),
3
);
assert_eq!(
estimated_sat_per_vbyte(&json!({"sat_per_kw":250})).unwrap(),
1
);
for v in [
json!({}),
json!({"sat_per_kw":0}),
json!({"sat_per_kw":-1}),
json!({"sat_per_kw":1250001}),
] {
assert!(estimated_sat_per_vbyte(&v).is_err());
}
}
#[test]
fn next_block_default_preserves_explicit_slower_and_custom_choices() {
assert_eq!(fee_options(&json!({})).unwrap(), (Some(1), None));
assert_eq!(
fee_options(&json!({"target_conf":null})).unwrap(),
(Some(1), None)
);
for target in [1, 3, 6, 144, 1008] {
assert_eq!(
fee_options(&json!({"target_conf":target})).unwrap(),
(Some(target), None)
);
}
for rate in [1, 17, 5000] {
assert_eq!(
fee_options(&json!({"sat_per_vbyte":rate})).unwrap(),
(None, Some(rate))
);
}
}
#[test]
fn malformed_explicit_fees_never_silently_become_fast() {
for value in [
json!(0),
json!(-1),
json!(1.5),
json!("6"),
json!(true),
json!({}),
json!(1009),
] {
assert!(fee_options(&json!({"target_conf":value})).is_err());
}
for value in [
json!(0),
json!(-1),
json!(1.5),
json!("6"),
json!(true),
json!(5001),
] {
assert!(fee_options(&json!({"sat_per_vbyte":value})).is_err());
}
assert!(fee_options(&json!({"target_conf":1,"sat_per_vbyte":2})).is_err());
}
}
+213 -51
View File
@@ -73,7 +73,86 @@ struct LndChannelBalanceResponse {
pending_open_local_balance: Option<LndAmount>, pending_open_local_balance: Option<LndAmount>,
} }
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
url: &str,
macaroon_hex: &str,
) -> Result<T> {
client
.get(url)
.header("Grpc-Metadata-macaroon", macaroon_hex)
.send()
.await
.context("LND is unavailable; balance could not be checked")?
.error_for_status()
.context("LND is not ready; balance could not be checked")?
.json()
.await
.context("LND returned invalid wallet data")
}
fn checked_balances(
wallet: LndBalanceResponse,
channels: LndChannelBalanceResponse,
) -> Result<(i64, i64, i64)> {
fn sats(value: Option<String>) -> Result<i64> {
let value = value.context("LND omitted a balance; balance is unavailable")?;
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
Ok(amount)
}
Ok((
sats(wallet.total_balance)?,
sats(channels.local_balance.and_then(|a| a.sat))?,
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
))
}
fn bitcoin_wait_state(
installed: bool,
running: bool,
fresh: bool,
ibd: Option<bool>,
) -> (&'static str, &'static str) {
if !installed {
("waiting_install", "Waiting for Bitcoin to be installed")
} else if !running {
("waiting_start", "Waiting for Bitcoin to start")
} else if !fresh || ibd.is_none() {
("waiting_start", "Waiting for Bitcoin to start")
} else if ibd == Some(true) {
("waiting_sync", "Waiting for Bitcoin to sync")
} else {
("bitcoin_ready", "Bitcoin is ready")
}
}
impl RpcHandler { impl RpcHandler {
pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value {
let (data, _) = self.state_manager.get_snapshot().await;
if !data.server_info.status_info.containers_scanned {
return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"});
}
let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"]
.iter()
.filter_map(|id| data.package_data.get(*id))
.collect();
let installed = !nodes.is_empty();
let running = nodes
.iter()
.any(|p| p.state == crate::data_model::PackageState::Running);
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
let ibd = bitcoin
.blockchain_info
.as_ref()
.and_then(|v| v.get("initialblockdownload"))
.and_then(|v| v.as_bool());
let (state, message) =
bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd);
serde_json::json!({"state": state, "message": message})
}
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> { pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
let macaroon_bytes = read_lnd_admin_macaroon().await?; let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes); let macaroon_hex = hex::encode(&macaroon_bytes);
@@ -85,45 +164,26 @@ impl RpcHandler {
.build() .build()
.context("Failed to create HTTP client")?; .context("Failed to create HTTP client")?;
let get_info: LndGetInfoResponse = client let get_info: LndGetInfoResponse = get_lnd_json(
.get(format!("{LND_REST_BASE_URL}/v1/getinfo")) &client,
.header("Grpc-Metadata-macaroon", &macaroon_hex) &format!("{LND_REST_BASE_URL}/v1/getinfo"),
.send() &macaroon_hex,
.await )
.context("LND REST connection failed")? .await?;
.json() let channel_balance: LndChannelBalanceResponse = get_lnd_json(
.await &client,
.context("Failed to parse LND getinfo response")?; &format!("{LND_REST_BASE_URL}/v1/balance/channels"),
&macaroon_hex,
let channel_balance: LndChannelBalanceResponse = match client )
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels")) .await?;
.header("Grpc-Metadata-macaroon", &macaroon_hex) let wallet_balance: LndBalanceResponse = get_lnd_json(
.send() &client,
.await &format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
{ &macaroon_hex,
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse { )
local_balance: None, .await?;
pending_open_local_balance: None, let (balance_sats, channel_balance_sats, pending_open_balance) =
}), checked_balances(wallet_balance, channel_balance)?;
Err(_) => LndChannelBalanceResponse {
local_balance: None,
pending_open_local_balance: None,
},
};
let wallet_balance: LndBalanceResponse = match client
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
{
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
total_balance: None,
}),
Err(_) => LndBalanceResponse {
total_balance: None,
},
};
let (identity_pubkey, uris) = map_identity(&get_info); let (identity_pubkey, uris) = map_identity(&get_info);
@@ -135,18 +195,9 @@ impl RpcHandler {
num_peers: get_info.num_peers.unwrap_or(0), num_peers: get_info.num_peers.unwrap_or(0),
synced_to_chain: get_info.synced_to_chain.unwrap_or(false), synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
block_height: get_info.block_height.unwrap_or(0), block_height: get_info.block_height.unwrap_or(0),
balance_sats: wallet_balance balance_sats,
.total_balance channel_balance_sats,
.and_then(|s| s.parse().ok()) pending_open_balance,
.unwrap_or(0),
channel_balance_sats: channel_balance
.local_balance
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
.unwrap_or(0),
pending_open_balance: channel_balance
.pending_open_local_balance
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
.unwrap_or(0),
}; };
Ok(serde_json::to_value(info)?) Ok(serde_json::to_value(info)?)
@@ -268,6 +319,76 @@ impl RpcHandler {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn unavailable_balances_are_not_zero() {
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
assert!(checked_balances(
serde_json::from_str(body).unwrap(),
serde_json::from_str(body).unwrap(),
)
.is_err());
}
for value in ["bad", "-1", "9223372036854775808"] {
let wallet = LndBalanceResponse {
total_balance: Some(value.into()),
};
let channels = serde_json::from_str(
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
)
.unwrap();
assert!(checked_balances(wallet, channels).is_err());
}
}
#[test]
fn verified_zero_and_nonzero_balances_survive() {
for expected in [0, 42] {
let wallet = LndBalanceResponse {
total_balance: Some(expected.to_string()),
};
let channels = serde_json::from_value(serde_json::json!({
"local_balance":{"sat":expected.to_string()},
"pending_open_local_balance":{"sat":"0"}
}))
.unwrap();
assert_eq!(
checked_balances(wallet, channels).unwrap(),
(expected, expected, 0)
);
}
}
#[tokio::test]
async fn locked_wallet_http_response_is_not_successful_getinfo() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0; 2048];
stream.read(&mut buf).await.unwrap();
let body =
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
stream.write_all(format!(
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
).as_bytes()).await.unwrap();
});
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap();
assert!(get_lnd_json::<LndGetInfoResponse>(
&client,
&format!("http://{addr}/v1/getinfo"),
"test"
)
.await
.is_err());
server.await.unwrap();
}
/// A real compressed secp256k1 pubkey shape: 66 hex characters. /// A real compressed secp256k1 pubkey shape: 66 hex characters.
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"; const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
@@ -341,3 +462,44 @@ mod tests {
assert!(!is_valid_identity_pubkey(&"g".repeat(66))); assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
} }
} }
#[cfg(test)]
mod dependency_readiness_tests {
use super::bitcoin_wait_state;
#[test]
fn waiting_states_cover_install_start_sync_outage_and_recovery() {
assert_eq!(
bitcoin_wait_state(false, false, false, None).0,
"waiting_install"
);
assert_eq!(
bitcoin_wait_state(true, false, false, None).0,
"waiting_start"
);
assert_eq!(
bitcoin_wait_state(true, true, false, None).0,
"waiting_start"
);
assert_eq!(
bitcoin_wait_state(true, true, true, Some(true)).0,
"waiting_sync"
);
assert_eq!(
bitcoin_wait_state(true, true, true, Some(false)).0,
"bitcoin_ready"
);
// Previously synced cached information must not hide a current outage.
assert_eq!(
bitcoin_wait_state(true, true, false, Some(false)).0,
"waiting_start"
);
assert_eq!(
bitcoin_wait_state(true, true, true, None).0,
"waiting_start"
);
assert_eq!(
bitcoin_wait_state(true, true, true, Some(false)).0,
"bitcoin_ready"
);
}
}
+66 -1
View File
@@ -1,6 +1,10 @@
mod channels; mod channels;
pub(super) mod external_invoice;
mod fee_bump;
mod fee_policy;
mod info; mod info;
mod macaroons; mod macaroons;
pub(super) mod onchain_purchase;
mod payments; mod payments;
mod seed_backup; mod seed_backup;
mod wallet; mod wallet;
@@ -133,12 +137,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
/// RPC-unreachable and locked-wallet states are deliberately NOT handled /// RPC-unreachable and locked-wallet states are deliberately NOT handled
/// here — container-down is crash-recovery's job, and unlocking needs the /// here — container-down is crash-recovery's job, and unlocking needs the
/// operator. /// operator.
fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool {
status.ok
&& !status.stale
&& status.age_ms < 30_000
&& status
.blockchain_info
.as_ref()
.and_then(|v| v.get("initialblockdownload"))
.and_then(|v| v.as_bool())
== Some(false)
}
pub(crate) fn spawn_lnd_health_watchdog() { pub(crate) fn spawn_lnd_health_watchdog() {
tokio::spawn(async move { tokio::spawn(async move {
let mut bad_minutes: u32 = 0; let mut bad_minutes: u32 = 0;
let mut last_restart: Option<tokio::time::Instant> = None; let mut last_restart: Option<tokio::time::Instant> = None;
let mut last_height: Option<u64> = None;
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await; tokio::time::sleep(std::time::Duration::from_secs(60)).await;
// Initial Bitcoin sync, warmup, and outages are dependencies to
// wait for, never evidence that LND is wedged. Do not accumulate
// restart pressure during a days-long initial block download.
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
if !bitcoin_ready_for_lnd_watchdog(&bitcoin)
|| crate::app_ops::lifecycle_op_in_flight("lnd")
{
bad_minutes = 0;
last_height = None;
continue;
}
let Ok(bytes) = read_lnd_admin_macaroon().await else { let Ok(bytes) = read_lnd_admin_macaroon().await else {
bad_minutes = 0; // no LND on this node (or not set up yet) bad_minutes = 0; // no LND on this node (or not set up yet)
continue; continue;
@@ -161,6 +189,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
bad_minutes = 0; // down/locked — not the wedge signature bad_minutes = 0; // down/locked — not the wedge signature
continue; continue;
}; };
if !resp.status().is_success() {
bad_minutes = 0;
continue;
}
let Ok(info) = resp.json::<serde_json::Value>().await else { let Ok(info) = resp.json::<serde_json::Value>().await else {
bad_minutes = 0; bad_minutes = 0;
continue; continue;
@@ -182,7 +214,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
.get("num_pending_channels") .get("num_pending_channels")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0); .unwrap_or(0);
let wedged = !synced || (channels > 0 && peers == 0); let height = info.get("block_height").and_then(|v| v.as_u64());
let progressing = height
.zip(last_height)
.is_some_and(|(now, before)| now > before);
last_height = height;
let wedged = !progressing && (!synced || (channels > 0 && peers == 0));
if !wedged { if !wedged {
bad_minutes = 0; bad_minutes = 0;
continue; continue;
@@ -239,3 +276,31 @@ impl RpcHandler {
Ok((client, macaroon_hex)) Ok((client, macaroon_hex))
} }
} }
#[cfg(test)]
mod watchdog_dependency_tests {
use super::bitcoin_ready_for_lnd_watchdog;
use crate::bitcoin_status::BitcoinNodeStatus;
use serde_json::json;
#[test]
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
let mut status = BitcoinNodeStatus::default();
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.ok = true;
status.blockchain_info = Some(json!({"initialblockdownload":true}));
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.blockchain_info = Some(json!({"initialblockdownload":false}));
assert!(bitcoin_ready_for_lnd_watchdog(&status));
status.stale = true;
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.stale = false;
status.ok = false;
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.ok = true;
status.age_ms = 30_000;
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.age_ms = 0;
status.blockchain_info = Some(json!({}));
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
}
}
File diff suppressed because it is too large Load Diff
+58 -27
View File
@@ -34,6 +34,33 @@ fn payment_failure_reason(reason: &str) -> &'static str {
} }
} }
/// Preserve terminal LND state as structured data. An RPC exception is an
/// ambiguous outcome to callers and must not hide a verified unpaid failure.
pub(super) fn router_payment_outcome(
payment: &serde_json::Value,
hash: &str,
decoded_amt: i64,
) -> serde_json::Value {
let status = match payment.get("status").and_then(|value| value.as_str()) {
Some("SUCCEEDED") => "succeeded",
Some("FAILED") => "failed",
_ => "pending",
};
let mut result = serde_json::json!({
"status": status, "payment_hash": hash,
"amount_sats": json_i64(payment, "value_sat").unwrap_or(decoded_amt),
});
if status == "failed" {
result["failure_reason"] = serde_json::json!(payment_failure_reason(
payment
.get("failure_reason")
.and_then(|value| value.as_str())
.unwrap_or("")
));
}
result
}
fn json_i64(value: &serde_json::Value, key: &str) -> Option<i64> { fn json_i64(value: &serde_json::Value, key: &str) -> Option<i64> {
value.get(key).and_then(|v| { value.get(key).and_then(|v| {
v.as_str() v.as_str()
@@ -190,33 +217,7 @@ impl RpcHandler {
return Err(payment_error(msg)); return Err(payment_error(msg));
} }
let payment = body.get("result").unwrap_or(&body); let payment = body.get("result").unwrap_or(&body);
match payment.get("status").and_then(|v| v.as_str()).unwrap_or("") { Ok(router_payment_outcome(payment, &decoded_hash, decoded_amt))
"SUCCEEDED" => {}
"FAILED" => {
let reason = payment
.get("failure_reason")
.and_then(|v| v.as_str())
.map(payment_failure_reason)
.unwrap_or("Payment failed");
return Err(anyhow::anyhow!("Payment failed: {reason}"));
}
_ => {
return Ok(serde_json::json!({
"status": "pending",
"payment_hash": decoded_hash,
"amount_sats": decoded_amt,
}));
}
}
let amount_sat = json_i64(payment, "value_sat").unwrap_or(decoded_amt);
Ok(serde_json::json!({
"status": "succeeded",
// The decode endpoint returns the canonical hex hash used by our
// polling/list APIs. Router's bytes field is base64 in REST JSON.
"payment_hash": decoded_hash,
"amount_sats": amount_sat,
}))
} }
/// Status of an outgoing Lightning payment by hex payment hash. Lets the /// Status of an outgoing Lightning payment by hex payment hash. Lets the
@@ -244,6 +245,10 @@ impl RpcHandler {
.send() .send()
.await .await
.context("LND REST connection failed")?; .context("LND REST connection failed")?;
anyhow::ensure!(
resp.status().is_success(),
"LND payment status is unavailable"
);
let body: serde_json::Value = resp let body: serde_json::Value = resp
.json() .json()
.await .await
@@ -402,6 +407,9 @@ impl RpcHandler {
})); }));
} }
self.group_fee_bump_history(raw_txs, &mut transactions, &client)
.await;
// Sort by timestamp descending (most recent first) // Sort by timestamp descending (most recent first)
transactions.sort_by(|a, b| { transactions.sort_by(|a, b| {
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0); let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
@@ -562,6 +570,29 @@ mod tests {
assert!(payment_error(msg).to_string().contains("fresh invoice")); assert!(payment_error(msg).to_string().contains("fresh invoice"));
} }
#[test]
fn terminal_router_failures_remain_distinct_from_ambiguous_payment_outcomes() {
let failed = router_payment_outcome(
&serde_json::json!({"status":"FAILED", "failure_reason":"FAILURE_REASON_INSUFFICIENT_BALANCE", "value_sat":"2"}),
&"ab".repeat(32),
0,
);
assert_eq!(failed["status"], "failed");
assert_eq!(failed["failure_reason"], "Insufficient channel balance");
assert_eq!(failed["amount_sats"], 2);
assert_eq!(failed["payment_hash"], "ab".repeat(32));
for status in ["IN_FLIGHT", "INITIATED", "UNKNOWN", ""] {
assert_eq!(
router_payment_outcome(&serde_json::json!({"status":status}), "", 2)["status"],
"pending"
);
}
assert_eq!(
router_payment_outcome(&serde_json::json!({"status":"SUCCEEDED"}), "", 2)["status"],
"succeeded"
);
}
#[test] #[test]
fn router_failure_reasons_are_actionable() { fn router_failure_reasons_are_actionable() {
assert_eq!( assert_eq!(
+481 -77
View File
@@ -7,6 +7,57 @@ use zeroize::Zeroize;
use super::LND_REST_BASE_URL; use super::LND_REST_BASE_URL;
impl RpcHandler { impl RpcHandler {
// Add inside api/rpc/lnd/wallet.rs RpcHandler impl; seller owns this lookup.
// Wire invoice-status response to this Value instead of reducing it to paid bool.
pub(crate) async fn content_invoice_lifecycle(
&self,
hash: &str,
content_id: &str,
) -> Result<serde_json::Value> {
anyhow::ensure!(
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid payment hash"
);
let hash = hash.to_ascii_lowercase();
let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?;
anyhow::ensure!(
existing.as_ref().is_none_or(|(id, _)| id == content_id),
"Invoice belongs to another content item"
);
if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await {
return Ok(
serde_json::json!({"paid":true,"state":"settled","can_switch_method":false}),
);
}
let (client, macaroon_hex) = self.lnd_client().await?;
let response = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(
serde_json::json!({"paid":false,"state":"unknown","can_switch_method":false}),
);
}
let body: serde_json::Value = response.error_for_status()?.json().await?;
let price = content_invoice_amount(&body, content_id)
.context("Invoice content binding is unavailable")?;
anyhow::ensure!(
existing
.as_ref()
.is_none_or(|(_, expected)| *expected == price),
"Invoice price binding changed"
);
crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price)
.await?;
let result = content_invoice_lifecycle_body(&body, price);
if result["paid"] == true {
crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?;
}
Ok(result)
}
/// Generate a new on-chain Bitcoin address. /// Generate a new on-chain Bitcoin address.
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> { pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| { let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| {
@@ -124,28 +175,8 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid Bitcoin address format")); return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
} }
// Fee control: either a confirmation target or an explicit fee rate // Omitted fees target the next block; explicit slower/custom choices win.
let target_conf = params.get("target_conf").and_then(|v| v.as_i64()); let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(&params)?;
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
if target_conf.is_some() && sat_per_vbyte.is_some() {
return Err(anyhow::anyhow!(
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
));
}
if let Some(tc) = target_conf {
if !(1..=1008).contains(&tc) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
}
if let Some(rate) = sat_per_vbyte {
if !(1..=5000).contains(&rate) {
return Err(anyhow::anyhow!(
"Invalid sat_per_vbyte: must be between 1 and 5000"
));
}
}
info!( info!(
addr = addr, addr = addr,
@@ -238,15 +269,12 @@ impl RpcHandler {
if !(546..=21_000_000 * 100_000_000).contains(&amount) { if !(546..=21_000_000 * 100_000_000).contains(&amount) {
return Err(anyhow::anyhow!("Invalid amount")); return Err(anyhow::anyhow!("Invalid amount"));
} }
let target_conf = params let (target_conf, custom_rate) = super::fee_policy::fee_options(&params)?;
.get("target_conf") anyhow::ensure!(
.and_then(|v| v.as_i64()) custom_rate.is_none(),
.unwrap_or(6); "Fee estimation requires a confirmation target"
if !(1..=1008).contains(&target_conf) { );
return Err(anyhow::anyhow!( let target_conf = target_conf.unwrap_or(super::fee_policy::DEFAULT_TARGET);
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
@@ -453,6 +481,56 @@ impl RpcHandler {
Ok(settled) Ok(settled)
} }
/// Verify against LND at download time, rather than relying on a browser
/// having polled first. The memo/amount also recover pre-upgrade in-memory
/// entitlements after restart; unrelated invoices never unlock a file.
pub(crate) async fn settle_content_invoice(
&self,
hash: &str,
content_id: &str,
) -> Result<bool> {
anyhow::ensure!(
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid payment hash"
);
let hash = hash.to_ascii_lowercase();
let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?;
if let Some((id, _)) = &existing {
if id != content_id {
return Ok(false);
}
}
if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await {
return Ok(true);
}
let (client, macaroon_hex) = self.lnd_client().await?;
let response = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
let body: serde_json::Value = response.error_for_status()?.json().await?;
let Some(price) = content_invoice_amount(&body, content_id) else {
return Ok(false);
};
if existing
.as_ref()
.is_some_and(|(_, expected)| *expected != price)
{
return Ok(false);
}
crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price)
.await?;
let settled = content_invoice_fully_settled(&body, price);
if settled {
crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?;
}
Ok(settled)
}
/// Generate a fresh on-chain receive address (seller side, #46). /// Generate a fresh on-chain receive address (seller side, #46).
pub(crate) async fn new_onchain_address(&self) -> Result<String> { pub(crate) async fn new_onchain_address(&self) -> Result<String> {
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
@@ -491,50 +569,15 @@ impl RpcHandler {
.send() .send()
.await .await
.context("Failed to list transactions")?; .context("Failed to list transactions")?;
if !resp.status().is_success() { anyhow::ensure!(
return Ok(false); resp.status().is_success(),
} "Wallet transaction verification is unavailable"
);
let body: serde_json::Value = resp let body: serde_json::Value = resp
.json() .json()
.await .await
.context("Failed to parse transactions response")?; .context("Failed to parse transactions response")?;
let i64_field = |tx: &serde_json::Value, k: &str| -> i64 { Ok(confirmed_address_sats(&body, address)? >= min_sats)
tx.get(k)
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| tx.get(k).and_then(|v| v.as_i64()))
.unwrap_or(0)
};
let txs = body
.get("transactions")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for tx in &txs {
if i64_field(tx, "num_confirmations") < 1 {
continue;
}
if i64_field(tx, "amount") < min_sats as i64 {
continue;
}
let pays_addr = tx
.get("dest_addresses")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|a| a.as_str() == Some(address)))
.unwrap_or(false)
|| tx
.get("output_details")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.any(|o| o.get("address").and_then(|a| a.as_str()) == Some(address))
})
.unwrap_or(false);
if pays_addr {
return Ok(true);
}
}
Ok(false)
} }
pub(in crate::api::rpc) async fn handle_lnd_createinvoice( pub(in crate::api::rpc) async fn handle_lnd_createinvoice(
@@ -732,10 +775,24 @@ impl RpcHandler {
total_amount += amount; total_amount += amount;
} }
let sat_per_vbyte = params let (_, explicit_rate) = super::fee_policy::fee_options(&serde_json::json!({
.get("fee_rate_sat_per_vbyte") "sat_per_vbyte": params.get("fee_rate_sat_per_vbyte")
.and_then(|v| v.as_u64()) }))?;
.unwrap_or(10); let (client, macaroon_hex) = self.lnd_client().await?;
let sat_per_vbyte = if let Some(rate) = explicit_rate {
rate as u64
} else {
let response = client
.get(format!("{LND_REST_BASE_URL}/v2/wallet/estimatefee/1"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Cannot estimate the next-block fee")?
.error_for_status()
.context("Next-block fee estimate rejected")?;
let estimate: serde_json::Value = response.json().await?;
super::fee_policy::estimated_sat_per_vbyte(&estimate)?
};
info!( info!(
total_amount = total_amount, total_amount = total_amount,
@@ -743,8 +800,6 @@ impl RpcHandler {
"Creating PSBT for hardware wallet signing" "Creating PSBT for hardware wallet signing"
); );
let (client, macaroon_hex) = self.lnd_client().await?;
let fund_body = serde_json::json!({ let fund_body = serde_json::json!({
"raw": { "raw": {
"outputs": lnd_outputs, "outputs": lnd_outputs,
@@ -1289,10 +1344,207 @@ fn psbt_key_origin_report(psbt_base64: &str) -> Result<PsbtKeyOriginReport> {
}) })
} }
/// LND's transaction `amount` is the wallet-wide net amount, not the value
/// paid to a purchase address. Attribute only confirmed output values, once
/// per outpoint. Missing/malformed evidence is unknown, never proof of payment.
pub(super) fn confirmed_address_sats(body: &serde_json::Value, address: &str) -> Result<u64> {
use std::collections::{HashMap, HashSet};
const MAX_SATS: u64 = 21_000_000 * 100_000_000;
fn integer(value: &serde_json::Value) -> Result<u64> {
match value {
serde_json::Value::String(s)
if !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()) =>
{
s.parse().context("Invalid on-chain output integer")
}
value => value
.as_u64()
.context("Missing or invalid on-chain output integer"),
}
}
anyhow::ensure!(!address.is_empty(), "Missing purchase address");
let transactions = body
.get("transactions")
.and_then(|v| v.as_array())
.context("Wallet omitted transaction evidence")?;
let mut seen = HashMap::new();
let mut total = 0u64;
for tx in transactions {
let confirmations = match &tx["num_confirmations"] {
serde_json::Value::String(s) => {
s.parse::<i64>().context("Invalid confirmation count")?
}
value => value.as_i64().context("Missing confirmation count")?,
};
if confirmations < 1 {
continue;
}
let hash = tx["tx_hash"]
.as_str()
.context("Missing transaction identifier")?;
anyhow::ensure!(
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid transaction identifier"
);
if let Some(previous) = seen.insert(hash.to_ascii_lowercase(), tx) {
anyhow::ensure!(previous == tx, "Conflicting duplicate transaction evidence");
continue;
}
let outputs = tx["output_details"]
.as_array()
.context("Wallet omitted output values")?;
let mut indices = HashSet::new();
for output in outputs {
let index =
u32::try_from(integer(&output["output_index"])?).context("Invalid output index")?;
anyhow::ensure!(indices.insert(index), "Duplicate transaction output");
let amount = integer(&output["amount"])?;
anyhow::ensure!(amount <= MAX_SATS, "Invalid output amount");
// A non-address script (e.g. OP_RETURN) has no receiving address.
if output["address"].as_str() != Some(address) {
continue;
}
total = total
.checked_add(amount)
.filter(|sum| *sum <= MAX_SATS)
.context("Invalid total received amount")?;
}
}
Ok(total)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn payment_tx(hash: &str, confirmations: i64, outputs: serde_json::Value) -> serde_json::Value {
serde_json::json!({"tx_hash":hash.repeat(64),"num_confirmations":confirmations,
"amount":"999999","dest_addresses":["purchase"],"output_details":outputs})
}
#[test]
fn onchain_purchase_counts_only_its_outputs_not_wallet_total() {
let tx = payment_tx(
"a",
1,
serde_json::json!([
{"output_index":"0","amount":"1","address":"purchase"},
{"output_index":"1","amount":"999998","address":"other"}
]),
);
assert_eq!(
confirmed_address_sats(&serde_json::json!({"transactions":[tx]}), "purchase").unwrap(),
1
);
}
#[test]
fn onchain_purchase_sums_confirmed_partial_outputs_once() {
let mut first = payment_tx(
"a",
1,
serde_json::json!([
{"output_index":0,"amount":"300","address":"purchase"},
{"output_index":"1","amount":46,"address":"purchase"}
]),
);
first["amount"] = serde_json::json!("-9999"); // net wallet debit is irrelevant
let second = payment_tx(
"b",
2,
serde_json::json!([
{"output_index":"2","amount":"200","address":"purchase"}
]),
);
let unconfirmed = payment_tx(
"c",
0,
serde_json::json!([
{"output_index":0,"amount":"10000","address":"purchase"}
]),
);
let conflicted = payment_tx(
"d",
-1,
serde_json::json!([
{"output_index":0,"amount":"10000","address":"purchase"}
]),
);
assert_eq!(confirmed_address_sats(&serde_json::json!({"transactions":[first.clone(),first,second,unconfirmed,conflicted]}), "purchase").unwrap(), 546);
}
#[test]
fn onchain_purchase_rejects_missing_values_and_ambiguous_outpoints() {
let good = payment_tx(
"a",
1,
serde_json::json!([
{"output_index":"0","amount":"546","address":"purchase"}
]),
);
let mut no_values = good.clone();
no_values.as_object_mut().unwrap().remove("output_details");
let mut duplicate = good.clone();
duplicate["output_details"] = serde_json::json!([
{"output_index":0,"amount":300,"address":"purchase"},
{"output_index":0,"amount":300,"address":"purchase"}
]);
let mut conflicting = good.clone();
conflicting["output_details"][0]["amount"] = serde_json::json!(1000);
for body in [
serde_json::json!({}),
serde_json::json!({"transactions":[no_values]}),
serde_json::json!({"transactions":[duplicate]}),
serde_json::json!({"transactions":[good.clone(),conflicting]}),
] {
assert!(confirmed_address_sats(&body, "purchase").is_err());
}
for amount in [
serde_json::json!(-1),
serde_json::json!("0.00000546"),
serde_json::json!(546.5),
serde_json::json!(null),
serde_json::json!("18446744073709551616"),
serde_json::json!("2100000000000001"),
] {
let mut invalid = good.clone();
invalid["output_details"][0]["amount"] = amount;
assert!(confirmed_address_sats(
&serde_json::json!({"transactions":[invalid]}),
"purchase"
)
.is_err());
}
}
#[test]
fn onchain_purchase_has_no_rounding_or_accumulation_overflow() {
let max = "2100000000000000";
let first = payment_tx(
"a",
1,
serde_json::json!([{"output_index":0,"amount":max,"address":"purchase"}]),
);
assert_eq!(
confirmed_address_sats(
&serde_json::json!({"transactions":[first.clone()]}),
"purchase"
)
.unwrap(),
2_100_000_000_000_000
);
let second = payment_tx(
"b",
1,
serde_json::json!([{"output_index":0,"amount":"1","address":"purchase"}]),
);
assert!(confirmed_address_sats(
&serde_json::json!({"transactions":[first,second]}),
"purchase"
)
.is_err());
}
/// Build a minimal, genuinely unsigned one-input PSBT with no key origin on /// Build a minimal, genuinely unsigned one-input PSBT with no key origin on
/// any input. Built programmatically rather than pasted as opaque base64 so /// any input. Built programmatically rather than pasted as opaque base64 so
/// the fixture states what it is. /// the fixture states what it is.
@@ -1444,3 +1696,155 @@ mod tests {
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}"); assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
} }
} }
// LND REST uses decimal strings for int64 fields. Match the complete seller
// memo, not a substring supplied by a buyer or an arbitrary settled invoice.
fn json_u64(value: &serde_json::Value) -> Option<u64> {
value.as_u64().or_else(|| value.as_str()?.parse().ok())
}
fn content_invoice_fully_settled(body: &serde_json::Value, price: u64) -> bool {
let settled = match body.get("state").and_then(|v| v.as_str()) {
Some(state) => state == "SETTLED",
None => body.get("settled").and_then(|v| v.as_bool()) == Some(true),
};
settled
&& price > 0
&& body
.get("amt_paid_sat")
.and_then(json_u64)
.is_some_and(|paid| paid >= price)
}
fn content_invoice_amount(body: &serde_json::Value, content_id: &str) -> Option<u64> {
if body.get("memo")?.as_str()? != format!("Archipelago peer file {content_id}") {
return None;
}
body.get("value").and_then(json_u64).filter(|v| *v > 0)
}
#[cfg(test)]
mod peer_file_invoice_tests {
use super::*;
#[test]
fn settlement_requires_terminal_state_and_full_amount() {
for state in ["OPEN", "ACCEPTED", "CANCELED", "unknown"] {
assert!(!content_invoice_fully_settled(
&serde_json::json!({"state":state,"settled":true,"amt_paid_sat":"100"}),
7
));
}
for amount in [
serde_json::json!(6),
serde_json::json!("-1"),
serde_json::json!(null),
serde_json::json!("bad"),
] {
assert!(!content_invoice_fully_settled(
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
7
));
}
for amount in [serde_json::json!(7), serde_json::json!("8")] {
assert!(content_invoice_fully_settled(
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
7
));
}
assert!(content_invoice_fully_settled(
&serde_json::json!({"settled":true,"amt_paid_sat":"7"}),
7
));
assert!(!content_invoice_fully_settled(
&serde_json::json!({"state":"SETTLED","amt_paid_sat":"7"}),
0
));
}
#[test]
fn legacy_recovery_requires_exact_file_memo_and_positive_amount() {
let invoice = serde_json::json!({"memo":"Archipelago peer file file-1", "value":"7"});
assert_eq!(content_invoice_amount(&invoice, "file-1"), Some(7));
assert_eq!(content_invoice_amount(&invoice, "file-2"), None);
for value in [
serde_json::json!("-1"),
serde_json::json!(0),
serde_json::json!("bad"),
] {
let mut invalid = invoice.clone();
invalid["value"] = value;
assert_eq!(content_invoice_amount(&invalid, "file-1"), None);
}
}
}
fn content_invoice_lifecycle_body(body: &serde_json::Value, price: u64) -> serde_json::Value {
let settled = content_invoice_fully_settled(body, price);
let cancelled = !settled
&& body["state"] == "CANCELED"
&& body.get("settled").and_then(|value| value.as_bool()) != Some(true)
&& body.get("amt_paid_sat").and_then(json_u64) == Some(0)
&& body
.get("amt_paid_msat")
.is_none_or(|value| json_u64(value) == Some(0));
let state = if settled {
"settled"
} else if cancelled {
"canceled"
} else {
match body.get("state").and_then(|value| value.as_str()) {
Some("OPEN") => "open",
Some("ACCEPTED") => "accepted",
_ => "unknown",
}
};
let expires_at = body
.get("creation_date")
.and_then(json_u64)
.zip(body.get("expiry").and_then(json_u64))
.and_then(|(created, expiry)| created.checked_add(expiry));
// Expiry is informational. Only LND's terminal canceled state releases the
// cross-method block; wall-clock passage or lookup failure never does.
serde_json::json!({"paid":settled,"state":state,"can_switch_method":cancelled,
"expires_at":expires_at,"cancel_supported":false})
}
#[cfg(test)]
mod invoice_lifecycle_tests {
use super::*;
#[test]
fn only_authoritative_zero_paid_cancel_unlocks_and_settlement_survives_expiry() {
for state in ["OPEN", "ACCEPTED", "UNKNOWN", "CANCELED"] {
for paid in [0u64, 1] {
let result = content_invoice_lifecycle_body(
&serde_json::json!({
"state":state,"settled":false,"amt_paid_sat":paid.to_string(),
"creation_date":"1","expiry":"1"}),
8,
);
assert_eq!(
result["can_switch_method"],
state == "CANCELED" && paid == 0
);
assert_eq!(result["paid"], false);
}
}
assert_eq!(
content_invoice_lifecycle_body(&serde_json::json!({"state":"CANCELED"}), 8)
["can_switch_method"],
false
);
assert_eq!(
content_invoice_lifecycle_body(
&serde_json::json!({"state":"CANCELED", "amt_paid_sat":"0", "amt_paid_msat":"1"}),
8
)["can_switch_method"],
false
);
let paid = content_invoice_lifecycle_body(
&serde_json::json!({
"state":"SETTLED","settled":true,"amt_paid_sat":"8","value":"8",
"creation_date":"1","expiry":"1"}),
8,
);
assert_eq!(paid["paid"], true);
assert_eq!(paid["can_switch_method"], false);
}
}
@@ -0,0 +1,353 @@
//! Owner-session/CSRF RPC plus producer-signed, exact media approval.
//! App callers use the native dashboard bridge; this is never an origin-only grant.
use super::RpcHandler;
use crate::media_registration::{AuthorizedSelection, Intent, Limits};
use anyhow::{Context, Result};
use nostr_sdk::prelude::{Event, Kind};
use serde::Deserialize;
use std::{
path::Path,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
// Dropping the request future cancels queued locks and chunked snapshot work.
// A completed durable record is still recovered by the original operation ID.
struct RequestCancellation(Arc<AtomicBool>);
impl Drop for RequestCancellation {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
fn request_cancellation() -> (RequestCancellation, Arc<AtomicBool>) {
let signal = Arc::new(AtomicBool::new(false));
(RequestCancellation(signal.clone()), signal)
}
const DOMAIN: &str = "archipelago.media-registration.approval.v1";
const KIND: u16 = 27236;
const MAX_APPROVAL: usize = 32 * 1024;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Params {
intent: Intent,
selection: AuthorizedSelection,
producer_event: Event,
}
const RESOLUTION_DOMAIN: &str = "archipelago.media-registration.resolution.v1";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ResolveParams {
intent: Intent,
producer_event: Event,
}
fn verified_resolution(input: serde_json::Value, now: u64) -> Result<ResolveParams> {
anyhow::ensure!(
serde_json::to_vec(&input)?.len() <= MAX_APPROVAL,
"Resolution approval is too large"
);
let params: ResolveParams = serde_json::from_value(input)?;
let event = &params.producer_event;
event
.verify()
.context("Resolution producer signature failed")?;
anyhow::ensure!(
event.kind == Kind::Custom(27237) && event.pubkey.to_hex() == params.intent.producer,
"Resolution signature purpose or producer changed"
);
let encoded = serde_json::to_value(event)?;
anyhow::ensure!(
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
&& event.created_at.as_u64() >= params.intent.created_at.saturating_sub(30)
&& event.created_at.as_u64() <= now.saturating_add(30),
"Invalid resolution signature time or scope"
);
let content: serde_json::Value = serde_json::from_str(&event.content)?;
anyhow::ensure!(
content
== serde_json::json!({
"action":"Recover prepared video or retire this expired incomplete registration",
"scope":RESOLUTION_DOMAIN, "intent":params.intent,
}),
"Resolution signature changed the original intent"
);
Ok(params)
}
fn approval_content(intent: &Intent, selection: &AuthorizedSelection) -> Result<serde_json::Value> {
let path = selection
.relative_path
.to_str()
.context("Cloud file name is not UTF-8")?;
Ok(serde_json::json!({
"action":"Register this Cloud video for an IndeeHub project",
"scope":DOMAIN,
"intent":intent,
"selection":{"cloudFile":path,"paymentMethods":selection.payment_methods},
}))
}
fn verified_producer(params: &Params, now: u64) -> Result<String> {
let event = &params.producer_event;
anyhow::ensure!(
event.kind == Kind::Custom(KIND),
"This signature is not a media registration approval"
);
event
.verify()
.context("Producer approval signature failed")?;
let producer = event.pubkey.to_hex();
anyhow::ensure!(
producer == params.intent.producer,
"The signing identity differs from the project producer"
);
let created = event.created_at.as_u64();
anyhow::ensure!(
created >= params.intent.created_at.saturating_sub(30)
&& created < params.intent.expires_at
&& created <= now.saturating_add(30),
"Producer approval was not signed within this registration intent"
);
let encoded = serde_json::to_value(event)?;
anyhow::ensure!(
encoded["tags"] == serde_json::json!([["d", DOMAIN]]),
"Media approval signature scope changed"
);
let content: serde_json::Value = serde_json::from_str(&event.content)
.context("Producer approval is not readable registration terms")?;
anyhow::ensure!(
content == approval_content(&params.intent, &params.selection)?,
"Approved project, Cloud selection or rental terms changed"
);
Ok(producer)
}
fn parse(input: serde_json::Value, now: u64) -> Result<(Params, String)> {
anyhow::ensure!(
serde_json::to_vec(&input)?.len() <= MAX_APPROVAL,
"Registration approval is too large"
);
let params: Params = serde_json::from_value(input).context("Invalid registration approval")?;
let producer = verified_producer(&params, now)?;
Ok((params, producer))
}
fn registration_limit(_data_dir: &Path) -> u64 {
// Explicit per-file staging bound; shared immutable snapshot storage handles
// disk reservations separately before this route is enabled for live apps.
16 * 1024 * 1024 * 1024
}
impl RpcHandler {
/// Read-only public installation bindings for the native consent bridge.
/// A hidden standalone signer must compare its actual app origin with these
/// installed addresses; a caller-supplied app name is never sufficient.
pub(super) async fn handle_media_registration_context(
&self,
_input: serde_json::Value,
) -> Result<serde_json::Value> {
let (state, _) = self.state_manager.get_snapshot().await;
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let data_dir = self.config.data_dir.clone();
let context = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&data_dir, &identity, &state)
})
.await??;
let mut result = serde_json::to_value(context)?;
result["paymentMethods"] = serde_json::json!(["cashu"]);
Ok(result)
}
pub(super) async fn handle_media_registration_resolve(
&self,
input: serde_json::Value,
) -> Result<serde_json::Value> {
let now = u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock")?;
let params = verified_resolution(input, now)?;
let (state, _) = self.state_manager.get_snapshot().await;
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let data_dir = self.config.data_dir.clone();
let (_cancellation, cancelled) = request_cancellation();
tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&data_dir, &identity, &state)?;
crate::registered_media::resolve_registration(
&data_dir,
&identity,
&params.intent,
&params.producer_event.pubkey.to_hex(),
now,
&Limits {
max_bytes: registration_limit(&data_dir),
cancelled: &cancelled,
},
)
})
.await?
}
/// Standard RPC front door already requires owner session, permitted origin
/// and CSRF. Producer signature is additional exact-scope consent, not a
/// replacement for those owner checks. A replay repeats the original UUID.
pub(super) async fn handle_media_registration_prepare(
&self,
input: serde_json::Value,
) -> Result<serde_json::Value> {
let now = u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock")?;
let (params, producer) = parse(input, now)?;
let (state, _) = self.state_manager.get_snapshot().await;
anyhow::ensure!(
state
.package_data
.get("indeedhub-api")
.is_some_and(|entry| matches!(
entry.state,
crate::data_model::PackageState::Running
)),
"The installed IndeeHub API must be running to register its media"
);
let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?;
let data_dir = self.config.data_dir.clone();
let (_cancellation, cancelled) = request_cancellation();
let receipt = tokio::task::spawn_blocking(move || {
crate::container::registration_pin::installed_context(&data_dir, &identity, &state)?;
let project = params.intent.project_id.clone();
let limits = Limits {
max_bytes: registration_limit(&data_dir),
cancelled: &cancelled,
};
crate::registered_media::register_approved_selection(
&data_dir,
&data_dir.join("filebrowser"),
&identity,
&crate::registered_media::ApprovedSelection {
authenticated_producer: &producer,
authenticated_project: &project,
intent: &params.intent,
selection: &params.selection,
},
now,
&limits,
|_| Ok(()),
)
})
.await??;
Ok(serde_json::to_value(receipt)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::prelude::{EventBuilder, Keys, Tag, Timestamp};
fn fixture() -> Params {
let keys = Keys::parse(&"07".repeat(32)).unwrap();
let intent = Intent {
version: 1,
request_id: uuid::Uuid::new_v4().to_string(),
nonce: "ab".repeat(32),
app_audience: uuid::Uuid::new_v4().to_string(),
node_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap(),
producer: keys.public_key().to_hex(),
project_id: "fixture-project".into(),
price_sats: 8,
viewing_seconds: 3600,
created_at: 1000,
expires_at: 1600,
};
let selection = AuthorizedSelection {
relative_path: "Movies/Film.mp4".into(),
payment_methods: vec!["cashu".into()],
};
let event = EventBuilder::new(
Kind::Custom(KIND),
serde_json::to_string_pretty(&approval_content(&intent, &selection).unwrap()).unwrap(),
)
.tag(Tag::identifier(DOMAIN))
.custom_created_at(Timestamp::from(1200))
.sign_with_keys(&keys)
.unwrap();
Params {
intent,
selection,
producer_event: event,
}
}
#[test]
fn producer_signature_binds_human_readable_exact_selection_terms_node_and_installation() {
let original = fixture();
assert_eq!(
verified_producer(&original, 1300).unwrap(),
original.intent.producer
);
// Original consent can recover an already-completed operation after
// expiry; underlying snapshot journal refuses creating a fresh one.
assert!(verified_producer(&original, 2000).is_ok());
let mut changed = fixture();
changed.intent.price_sats += 1;
assert!(verified_producer(&changed, 1300).is_err());
let mut changed = fixture();
changed.selection.relative_path = "Other.mp4".into();
assert!(verified_producer(&changed, 1300).is_err());
let mut changed = fixture();
changed.intent.app_audience = uuid::Uuid::new_v4().to_string();
assert!(verified_producer(&changed, 1300).is_err());
let mut changed = fixture();
changed.intent.producer = "cd".repeat(32);
assert!(verified_producer(&changed, 1300).is_err());
assert!(verified_producer(&fixture(), 1000).is_err());
}
#[test]
fn request_parser_rejects_unsigned_claims_unknown_fields_and_changed_signed_content() {
let original = fixture();
let mut encoded = serde_json::json!({"intent":original.intent,"selection":original.selection,"producerEvent":original.producer_event});
assert!(parse(encoded.clone(), 1300).is_ok());
encoded["producerEvent"]["content"] = serde_json::json!("approve everything");
assert!(parse(encoded, 1300).is_err());
assert!(parse(serde_json::json!({"producer":"cd".repeat(32)}), 1300).is_err());
}
#[test]
fn dropped_request_signals_blocking_copy_cancellation() {
let (guard, signal) = request_cancellation();
assert!(!signal.load(Ordering::Relaxed));
drop(guard);
assert!(signal.load(Ordering::Relaxed));
}
#[test]
fn resolution_signature_recovers_only_exact_intent_after_expiry_without_file_authority() {
let original = fixture();
let keys = Keys::parse(&"07".repeat(32)).unwrap();
let event = EventBuilder::new(
Kind::Custom(27237),
serde_json::json!({
"action":"Recover prepared video or retire this expired incomplete registration",
"scope":RESOLUTION_DOMAIN,"intent":original.intent,
})
.to_string(),
)
.tag(Tag::identifier(RESOLUTION_DOMAIN))
.custom_created_at(Timestamp::from(1700))
.sign_with_keys(&keys)
.unwrap();
let encoded = serde_json::json!({"intent":original.intent,"producerEvent":event});
assert!(verified_resolution(encoded.clone(), 1800).is_ok());
assert!(verified_resolution(encoded.clone(), 9999).is_ok());
assert!(parse(encoded.clone(), 1800).is_err());
let mut changed = encoded.clone();
changed["intent"]["priceSats"] = serde_json::json!(999);
assert!(verified_resolution(changed, 1800).is_err());
let mut changed = encoded;
changed["selection"] =
serde_json::json!({"relative_path":"film.mp4","payment_methods":["cashu"]});
assert!(verified_resolution(changed, 1800).is_err());
assert!(verified_resolution(
serde_json::json!({"intent":original.intent,"producerEvent":original.producer_event}),
1800
)
.is_err());
}
}
@@ -221,6 +221,10 @@ impl RpcHandler {
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
if let Some(job) = self.flash_job.read().await.as_ref() {
anyhow::ensure!(job.snapshot().await.done,
"A firmware flash is in progress; wait before reconnecting or changing radio settings");
}
let mut config = mesh::load_config(&self.config.data_dir).await?; let mut config = mesh::load_config(&self.config.data_dir).await?;
@@ -325,7 +329,16 @@ impl RpcHandler {
{ {
let service_arc = Arc::clone(&self.mesh_service); let service_arc = Arc::clone(&self.mesh_service);
let config_for_apply = config.clone(); let config_for_apply = config.clone();
let flash_jobs = Arc::clone(&self.flash_job);
tokio::spawn(async move { tokio::spawn(async move {
// Serialize against flash registration. If a flash started
// after this RPC saved settings, its completion applies them.
let flash_guard = flash_jobs.read().await;
if let Some(job) = flash_guard.as_ref() {
if !job.snapshot().await.done {
return;
}
}
let mut service = service_arc.write().await; let mut service = service_arc.write().await;
if let Some(svc) = service.as_mut() { if let Some(svc) = service.as_mut() {
if let Err(e) = svc.configure(config_for_apply).await { if let Err(e) = svc.configure(config_for_apply).await {
+3 -1
View File
@@ -110,7 +110,8 @@ impl RpcHandler {
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe) // `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
// from opening the identical port at the same time and corrupting // from opening the identical port at the same time and corrupting
// both operations' handshakes. // both operations' handshakes.
if let Some(job) = self.flash_job.read().await.as_ref() { let flash_guard = self.flash_job.read().await;
if let Some(job) = flash_guard.as_ref() {
anyhow::ensure!( anyhow::ensure!(
job.snapshot().await.done, job.snapshot().await.done,
"A firmware flash is in progress — refusing to probe the serial port until it finishes" "A firmware flash is in progress — refusing to probe the serial port until it finishes"
@@ -131,6 +132,7 @@ impl RpcHandler {
} }
} }
let probe = mesh::listener::probe_device(&path).await?; let probe = mesh::listener::probe_device(&path).await?;
drop(flash_guard);
Ok(serde_json::to_value(probe)?) Ok(serde_json::to_value(probe)?)
} }
+14 -3
View File
@@ -64,6 +64,11 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"must be", "must be",
"cannot", "cannot",
"Password", "Password",
// auth.changePassword verifies the existing node password before it
// writes either the web hash or the optional Linux/SSH password. This
// is safe, actionable validation text; masking it as an internal
// failure sent operators to the server logs for a simple typo.
"Current password is incorrect",
// OTA apply/download errors are all operator-actionable ("download it // OTA apply/download errors are all operator-actionable ("download it
// again", "download first") — sanitizing them to "Operation failed" // again", "download first") — sanitizing them to "Operation failed"
// left users stuck with no idea what to do, and hid the "already // left users stuck with no idea what to do, and hid the "already
@@ -242,6 +247,12 @@ mod sanitize_tests {
assert_eq!(sanitize_error_message(msg), msg); assert_eq!(sanitize_error_message(msg), msg);
} }
#[test]
fn change_password_rejection_reaches_the_operator() {
let msg = "Current password is incorrect";
assert_eq!(sanitize_error_message(msg), msg);
}
#[test] #[test]
fn tor_unavailable_precondition_passes_through() { fn tor_unavailable_precondition_passes_through() {
let msg = "Tor address not available. Tor may not be running."; let msg = "Tor address not available. Tor may not be running.";
@@ -306,14 +317,14 @@ mod sanitize_tests {
/// Deterministic: same session token always produces the same CSRF token. /// Deterministic: same session token always produces the same CSRF token.
/// Survives backend restarts because it depends only on the session token /// Survives backend restarts because it depends only on the session token
/// and the on-disk remember secret (not ephemeral state). /// and the on-disk remember secret (not ephemeral state).
pub(super) async fn derive_csrf_token(session_token: &str) -> String { pub(crate) async fn derive_csrf_token(session_token: &str) -> std::io::Result<String> {
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use sha2::Sha256; use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
let secret = SessionStore::load_or_create_remember_secret().await; let secret = SessionStore::load_or_create_remember_secret().await?;
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key"); let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
mac.update(format!("csrf:{}", session_token).as_bytes()); mac.update(format!("csrf:{}", session_token).as_bytes());
hex::encode(mac.finalize().into_bytes()) Ok(hex::encode(mac.finalize().into_bytes()))
} }
/// Extract a named cookie value from headers. /// Extract a named cookie value from headers.
+325 -49
View File
@@ -17,8 +17,13 @@ mod fips;
mod handshake; mod handshake;
mod identity; mod identity;
mod interfaces; mod interfaces;
mod lightning_purchase;
mod onchain_purchase;
pub(crate) mod lnd; pub(crate) mod lnd;
mod marketplace; mod marketplace;
mod media_registration;
mod playback;
mod purchase;
// pub(crate): 13-10's `assistant::backends::select_backend` reuses // pub(crate): 13-10's `assistant::backends::select_backend` reuses
// `mesh::assistant::detect_ollama()` (D-04) rather than re-probing — // `mesh::assistant::detect_ollama()` (D-04) rather than re-probing —
// matches the existing `pub(crate) mod bitcoin_relay;`/`pub(crate) mod // matches the existing `pub(crate) mod bitcoin_relay;`/`pub(crate) mod
@@ -34,6 +39,7 @@ mod nostr;
mod onboarding_gate; mod onboarding_gate;
mod openwrt; mod openwrt;
mod package; mod package;
pub(crate) use package::patch_indeedhub_nostr_provider;
pub(crate) use package::wyoming_satellite_keeper; pub(crate) use package::wyoming_satellite_keeper;
mod peers; mod peers;
mod pine_status; mod pine_status;
@@ -71,12 +77,87 @@ pub use middleware::PeerAddr;
// never added to it — the Phase-10 hard constraint this crate must hold. // never added to it — the Phase-10 hard constraint this crate must hold.
// The list's *contents* are unchanged; only its read-visibility widens from // The list's *contents* are unchanged; only its read-visibility widens from
// "this module" to "this crate". // "this module" to "this crate".
pub(crate) use middleware::UNAUTHENTICATED_METHODS; pub(crate) use middleware::{derive_csrf_token, UNAUTHENTICATED_METHODS};
use middleware::{ use middleware::{extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS};
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS,
};
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse}; use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
/// Browser apps run on dedicated high ports and can share the authenticated
/// node cookie. Nostr signing must therefore be callable by the dashboard
/// bridge (ports 80/443), not directly by an iframe that could bypass its
/// consent dialog. Requests without Origin remain available to authenticated
/// local CLI/integration clients. Development permits loopback origins.
fn nostr_signing_origin_allowed(headers: &hyper::HeaderMap, dev_mode: bool) -> bool {
let Some(origin) = headers.get("origin").and_then(|value| value.to_str().ok()) else {
return true;
};
let Ok(url) = reqwest::Url::parse(origin) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return false;
}
if dev_mode && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) {
return true;
}
matches!(url.port_or_known_default(), Some(80 | 443))
}
fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_mode: bool) -> bool {
!matches!(
method,
"node.nostr-sign"
| "identity.nostr-sign"
| "media.registration.prepare"
| "media.registration.context"
| "media.registration.resolve"
| "content.rental-purchase"
| "content.onchain-cancel"
| "content.onchain-attempt"
| "content.onchain-create"
| "content.onchain-expose"
| "content.onchain-prepare"
| "content.onchain-pay"
| "content.onchain-recover"
| "content.onchain-download"
| "content.invoice-pay"
| "content.invoice-download"
| "content.invoice-attempt"
| "content.invoice-retry-native"
| "content.invoice-create"
| "content.invoice-recover"
| "content.invoice-cancel"
| "content.purchase"
| "content.cancel-purchase"
| "content.playback-handle"
| "content.playback-status"
| "content.playback-prepare"
| "content.playback-start"
) || nostr_signing_origin_allowed(headers, dev_mode)
}
/// Read-only authenticated methods may skip CSRF, but they must still exist in
/// the dispatcher. The tab signer uses `system.get-hostname` as its lightweight
/// session probe, so keeping the policy in one testable function protects that
/// cross-origin app-gate bootstrap contract.
fn csrf_exempt_method(method: &str) -> bool {
matches!(
method,
"node-messages-received"
| "server.echo"
| "server.get-state"
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
| "system.get-metrics"
| "system.get-hostname"
)
}
/// Default dev password when no user is set up (matches mock-backend). /// Default dev password when no user is set up (matches mock-backend).
/// Dev builds only — the pre-setup login bypass that reads this is /// Dev builds only — the pre-setup login bypass that reads this is
/// cfg-gated out of release binaries. /// cfg-gated out of release binaries.
@@ -106,6 +187,7 @@ pub struct RpcHandler {
pub(crate) app_gate: Arc<crate::appgate::AppGate>, pub(crate) app_gate: Arc<crate::appgate::AppGate>,
endpoint_rate_limiter: EndpointRateLimiter, endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache, response_cache: ResponseCache,
playback_handles: crate::playback_handles::PlaybackHandles,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>, mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one /// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
/// job at a time, since flashing needs exclusive access to the port. /// job at a time, since flashing needs exclusive access to the port.
@@ -130,6 +212,10 @@ pub struct RpcHandler {
} }
impl RpcHandler { impl RpcHandler {
pub(crate) fn playback_handles(&self) -> &crate::playback_handles::PlaybackHandles {
&self.playback_handles
}
pub async fn new( pub async fn new(
config: Config, config: Config,
state_manager: Arc<StateManager>, state_manager: Arc<StateManager>,
@@ -189,6 +275,7 @@ impl RpcHandler {
app_gate, app_gate,
endpoint_rate_limiter, endpoint_rate_limiter,
response_cache: ResponseCache::new(5), response_cache: ResponseCache::new(5),
playback_handles: Default::default(),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)), mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
flash_job: crate::mesh::flash::new_job_handle(), flash_job: crate::mesh::flash::new_job_handle(),
transport_router: Arc::new(tokio::sync::RwLock::new(None)), transport_router: Arc::new(tokio::sync::RwLock::new(None)),
@@ -291,8 +378,26 @@ impl RpcHandler {
debug!("RPC method: {}", rpc_req.method); debug!("RPC method: {}", rpc_req.method);
if !native_consent_origin_allowed(&rpc_req.method, &parts.headers, self.config.dev_mode) {
return Ok(self.error_response(
403,
"Native signing and Cloud registration from app origins require the dashboard consent bridge",
StatusCode::FORBIDDEN,
));
}
// Enforce authentication for non-allowlisted methods // Enforce authentication for non-allowlisted methods
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str()); let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
if !is_unauthenticated || rpc_req.method.starts_with("auth.") {
if let Err(error) = SessionStore::load_or_create_remember_secret().await {
tracing::error!(%error, "Persistent session signing key unavailable");
return Ok(self.error_response(
503,
"Sign-in temporarily unavailable. Check server session storage.",
StatusCode::SERVICE_UNAVAILABLE,
));
}
}
let mut new_session_cookies: Option<(String, String)> = None; let mut new_session_cookies: Option<(String, String)> = None;
if !is_unauthenticated { if !is_unauthenticated {
let mut authenticated = match &session_token { let mut authenticated = match &session_token {
@@ -305,7 +410,7 @@ impl RpcHandler {
if let Some(remember) = extract_cookie(&parts.headers, "remember") { if let Some(remember) = extract_cookie(&parts.headers, "remember") {
if crate::session::SessionStore::validate_remember_token(&remember).await { if crate::session::SessionStore::validate_remember_token(&remember).await {
let new_token = self.session_store.create().await; let new_token = self.session_store.create().await;
let new_csrf = derive_csrf_token(&new_token).await; let new_csrf = derive_csrf_token(&new_token).await?;
tracing::info!("Auto-restored session from remember-me token"); tracing::info!("Auto-restored session from remember-me token");
new_session_cookies = Some((new_token, new_csrf)); new_session_cookies = Some((new_token, new_csrf));
authenticated = true; authenticated = true;
@@ -340,21 +445,7 @@ impl RpcHandler {
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token. // CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery. // Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie). // Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
let csrf_exempt = matches!( let csrf_exempt = csrf_exempt_method(&rpc_req.method);
rpc_req.method.as_str(),
"node-messages-received"
| "server.echo"
| "server.get-state"
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
| "system.get-metrics"
| "system.get-version"
);
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt { if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
let csrf_header = parts let csrf_header = parts
.headers .headers
@@ -367,7 +458,7 @@ impl RpcHandler {
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use sha2::Sha256; use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
let secret = SessionStore::load_or_create_remember_secret().await; let secret = SessionStore::load_or_create_remember_secret().await?;
let mut mac = match HmacSha256::new_from_slice(&secret) { let mut mac = match HmacSha256::new_from_slice(&secret) {
Ok(m) => m, Ok(m) => m,
Err(_) => { Err(_) => {
@@ -384,29 +475,28 @@ impl RpcHandler {
}; };
if !csrf_valid { if !csrf_valid {
// Debug: log expected vs received for diagnosis tracing::warn!(method = %rpc_req.method, "CSRF mismatch; rejecting action and refreshing authenticated session token");
if let (Some(token), Some(header)) = (&session_token, &csrf_header) { let mut response = self.error_response(
let expected = derive_csrf_token(token).await;
tracing::warn!(
method = %rpc_req.method,
session_prefix = %&token[..8.min(token.len())],
csrf_prefix = %&header[..8.min(header.len())],
expected_prefix = %&expected[..8.min(expected.len())],
"403 CSRF mismatch — session/csrf/expected prefixes shown"
);
} else {
tracing::warn!(
method = %rpc_req.method,
has_session = session_token.is_some(),
has_header = csrf_header.is_some(),
"403 CSRF validation failed — rejecting RPC call"
);
}
return Ok(self.error_response(
403, 403,
"CSRF token missing or invalid", "CSRF token missing or invalid",
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
)); );
// Authentication and RBAC have already passed. Reject this
// request without dispatch, but refresh the deterministic CSRF
// cookie so the browser can retry normally after a key rotation
// or a stale companion cookie. Never return a token to an
// unauthenticated client or relax CSRF validation on retry.
if let Some(token) = &session_token {
self.set_csrf_cookie(
&mut response,
&derive_csrf_token(token).await?,
secure_suffix,
);
}
response
.headers_mut()
.insert("Cache-Control", cookie_header("private, no-store"));
return Ok(response);
} }
} }
@@ -511,7 +601,7 @@ impl RpcHandler {
client_ip, client_ip,
secure_suffix, secure_suffix,
) )
.await; .await?;
Ok(response) Ok(response)
} }
@@ -563,7 +653,7 @@ impl RpcHandler {
new_session_cookies: &Option<(String, String)>, new_session_cookies: &Option<(String, String)>,
client_ip: std::net::IpAddr, client_ip: std::net::IpAddr,
secure_suffix: &str, secure_suffix: &str,
) { ) -> Result<()> {
// Track failed login attempts for rate limiting // Track failed login attempts for rate limiting
if method == "auth.login" && rpc_resp.error.is_some() { if method == "auth.login" && rpc_resp.error.is_some() {
self.login_rate_limiter.record_failure(client_ip).await; self.login_rate_limiter.record_failure(client_ip).await;
@@ -603,7 +693,7 @@ impl RpcHandler {
if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await { if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await {
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) { if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
let token = self.session_store.create_pending(secret).await; let token = self.session_store.create_pending(secret).await;
let csrf_token = derive_csrf_token(&token).await; let csrf_token = derive_csrf_token(&token).await?;
self.set_session_cookie(response, &token, secure_suffix); self.set_session_cookie(response, &token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix); self.set_csrf_cookie(response, &csrf_token, secure_suffix);
let totp_body = serde_json::json!({ let totp_body = serde_json::json!({
@@ -616,8 +706,8 @@ impl RpcHandler {
} }
} else { } else {
let token = self.session_store.create().await; let token = self.session_store.create().await;
let csrf_token = derive_csrf_token(&token).await; let csrf_token = derive_csrf_token(&token).await?;
let remember_token = self.session_store.create_remember_token().await; let remember_token = self.session_store.create_remember_token().await?;
self.set_session_cookie(response, &token, secure_suffix); self.set_session_cookie(response, &token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix); self.set_csrf_cookie(response, &csrf_token, secure_suffix);
self.set_remember_cookie(response, &remember_token, secure_suffix); self.set_remember_cookie(response, &remember_token, secure_suffix);
@@ -636,8 +726,8 @@ impl RpcHandler {
.map(|s| s.to_string()); .map(|s| s.to_string());
if let Some(new_token) = new_token_opt { if let Some(new_token) = new_token_opt {
let csrf_token = derive_csrf_token(&new_token).await; let csrf_token = derive_csrf_token(&new_token).await?;
let remember_token = self.session_store.create_remember_token().await; let remember_token = self.session_store.create_remember_token().await?;
self.set_session_cookie(response, &new_token, secure_suffix); self.set_session_cookie(response, &new_token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix); self.set_csrf_cookie(response, &csrf_token, secure_suffix);
self.set_remember_cookie(response, &remember_token, secure_suffix); self.set_remember_cookie(response, &remember_token, secure_suffix);
@@ -656,7 +746,7 @@ impl RpcHandler {
if method == "auth.changePassword" && rpc_resp.error.is_none() { if method == "auth.changePassword" && rpc_resp.error.is_none() {
if let Some(token) = session_token { if let Some(token) = session_token {
let new_token = self.session_store.rotate(token).await; let new_token = self.session_store.rotate(token).await;
let csrf_token = derive_csrf_token(&new_token).await; let csrf_token = derive_csrf_token(&new_token).await?;
self.set_session_cookie(response, &new_token, secure_suffix); self.set_session_cookie(response, &new_token, secure_suffix);
self.set_csrf_cookie(response, &csrf_token, secure_suffix); self.set_csrf_cookie(response, &csrf_token, secure_suffix);
} }
@@ -688,6 +778,7 @@ impl RpcHandler {
self.set_session_cookie(response, new_session, secure_suffix); self.set_session_cookie(response, new_session, secure_suffix);
self.set_csrf_cookie(response, new_csrf, secure_suffix); self.set_csrf_cookie(response, new_csrf, secure_suffix);
} }
Ok(())
} }
fn set_session_cookie( fn set_session_cookie(
@@ -735,3 +826,188 @@ impl RpcHandler {
); );
} }
} }
#[cfg(test)]
mod nostr_signing_origin_tests {
use super::*;
use hyper::header::{HeaderMap, HeaderValue, ORIGIN};
fn headers(origin: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
headers
}
#[test]
fn native_registration_and_purchase_use_dashboard_origin_and_keep_authentication_and_csrf() {
for method in [
"media.registration.prepare",
"media.registration.context",
"media.registration.resolve",
"content.rental-purchase",
"content.onchain-cancel",
"content.onchain-attempt",
"content.onchain-create",
"content.onchain-expose",
"content.onchain-prepare",
"content.onchain-pay",
"content.onchain-recover",
"content.onchain-download",
"content.purchase",
"content.cancel-purchase",
"content.playback-handle",
"content.playback-status",
"content.playback-prepare",
"content.playback-start",
] {
assert!(!native_consent_origin_allowed(
method,
&headers(Some("http://node.local:7778")),
false
));
assert!(native_consent_origin_allowed(
method,
&headers(Some("https://node.local")),
false
));
assert!(!UNAUTHENTICATED_METHODS.contains(&method));
assert!(!csrf_exempt_method(method));
}
}
#[test]
fn signing_accepts_dashboard_and_authenticated_non_browser_clients() {
assert!(nostr_signing_origin_allowed(&headers(None), false));
assert!(nostr_signing_origin_allowed(
&headers(Some("https://node.local")),
false
));
assert!(nostr_signing_origin_allowed(
&headers(Some("http://192.0.2.10")),
false
));
}
#[test]
fn signing_rejects_app_ports_but_allows_loopback_dev_server() {
assert!(!nostr_signing_origin_allowed(
&headers(Some("https://node.local:8337")),
false
));
assert!(!nostr_signing_origin_allowed(
&headers(Some("https://node.local:7778")),
false
));
assert!(nostr_signing_origin_allowed(
&headers(Some("http://localhost:5173")),
true
));
}
}
#[cfg(test)]
mod session_probe_contract_tests {
use super::*;
#[test]
fn signer_session_probe_is_implemented_authenticated_and_read_only() {
const PROBE: &str = "system.get-hostname";
const DISPATCHER: &str = include_str!("dispatcher.rs");
assert!(csrf_exempt_method(PROBE));
assert!(!UNAUTHENTICATED_METHODS.contains(&PROBE));
assert!(DISPATCHER.contains("\"system.get-hostname\" =>"));
assert!(!DISPATCHER.contains("\"system.get-version\" =>"));
}
}
#[cfg(test)]
mod csrf_recovery_tests {
use super::*;
#[tokio::test]
async fn stale_csrf_is_refreshed_without_executing_action_or_authenticating_strangers() {
let dir = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = dir.path().to_path_buf();
config.dev_mode = false;
let sessions =
crate::session::SessionStore::new_for_tests(dir.path().join("sessions.json"));
let token = sessions.create().await;
let handler = Arc::new(
RpcHandler::new(
config,
Arc::new(crate::state::StateManager::new()),
Arc::new(crate::monitoring::MetricsStore::new()),
sessions,
None,
None,
)
.await
.unwrap(),
);
let request = |session: &str, csrf: Option<&str>, secure: bool| {
let mut builder = Request::builder()
.method("POST")
.uri("/rpc/v1")
.header("Cookie", format!("session={session}"));
if let Some(csrf) = csrf {
builder = builder.header("X-CSRF-Token", csrf);
}
if secure {
builder = builder.header("X-Forwarded-Proto", "https");
}
builder.body(hyper::Body::from(serde_json::json!({
"jsonrpc":"2.0", "id":1, "method":"system.settings.set",
"params":{"key":"ai_provider", "value":"{\"provider\":\"local\",\"openai_model\":\"\"}"}
}).to_string())).unwrap()
};
let settings = dir.path().join("settings/model-provider.json");
for stale in [None, Some("stale-token"), Some("00")] {
let response = handler
.clone()
.handle(request(&token, stale, true))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(!settings.exists(), "rejected action must not have run");
let cookies: Vec<_> = response
.headers()
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap())
.collect();
assert_eq!(cookies.len(), 1);
let expected = derive_csrf_token(&token).await.unwrap();
assert_eq!(
cookies[0],
format!("csrf_token={expected}; SameSite=Lax; Path=/; Secure")
);
assert_eq!(response.headers()["cache-control"], "private, no-store");
}
let stranger = handler
.clone()
.handle(request("not-a-session", None, true))
.await
.unwrap();
assert_eq!(stranger.status(), StatusCode::UNAUTHORIZED);
assert!(!stranger.headers().contains_key("set-cookie"));
assert!(!settings.exists());
let valid = derive_csrf_token(&token).await.unwrap();
let response = handler
.handle(request(&token, Some(&valid), false))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value =
serde_json::from_slice(&hyper::body::to_bytes(response.into_body()).await.unwrap())
.unwrap();
assert!(
body["error"].is_null(),
"valid retry must reach the handler"
);
assert!(settings.exists());
}
}
@@ -0,0 +1,983 @@
//! Owner-only original-operation on-chain flow. No generic sendcoins fallback.
use super::RpcHandler;
use crate::{
content_lightning::Binding,
content_onchain::{self as engine, Journal, Phase, Record},
};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Params {
onion: String,
content_id: String,
operation_id: Option<String>,
price_sats: Option<u64>,
max_fee_sats: Option<u64>,
sat_per_vbyte: Option<u64>,
template_sha256: Option<String>,
plan_sha256: Option<String>,
}
fn public(record: &Record) -> Result<Value> {
let fee = record
.template
.as_ref()
.map(|t| engine::validate_funded(record, t))
.transpose()?;
let template_sha256 = record
.template
.as_ref()
.map(|t| hex::encode(Sha256::digest(t.psbt_base64.as_bytes())));
Ok(
json!({"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"phase":record.phase,
"network":record.network(),"external_exposure":record.externally_exposed,
"address":if record.externally_exposed {record.quote.as_ref().map(|q|q.address.as_str())}else{None},
"fee_sats":fee.or_else(|| record.plan.as_ref().map(|p|p.fee_sats)),
"max_fee_sats":record.policy.as_ref().map(|p|p.max_fee_sats).or_else(||record.plan.as_ref().map(|p|p.max_fee_sats)),
"template_sha256":template_sha256,"plan_sha256":record.plan.as_ref().map(|p|p.hash()).transpose()?,
"txid":record.signed.as_ref().map(|s|s.txid.as_str()),"paid":record.settled,
"change_allocation_ambiguous":matches!(record.change_address,Some(engine::ChangeAddress::Dispatched)),
"can_switch_method":record.retirement.is_some(),"retired_unallocated":record.retirement.is_some()}),
)
}
impl RpcHandler {
async fn request_onchain_allocation(
&self,
record: &Record,
fips: &str,
) -> Result<crate::content_onchain_seller::Record> {
let operation = crate::api::handler::onchain_purchase::Operation {
binding: record.binding.clone(),
action: "allocate".into(),
};
let (mut response, _) = crate::fips::dial::PeerRequest::new(
Some(fips),
&record.seller_onion,
crate::api::handler::onchain_purchase::ROUTE,
)
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(45))
.send_content_json(
&self.config.data_dir,
&record.binding.seller_did,
&operation,
)
.await
.context("Original seller allocation reply unavailable; recover the same operation")?;
anyhow::ensure!(
response.status().is_success(),
"Original seller allocation remains unresolved"
);
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
bytes.len() + chunk.len() <= 16384,
"Seller response too large"
);
bytes.extend_from_slice(&chunk);
}
let saved: crate::content_onchain_seller::Record = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
saved.binding == record.binding,
"Seller changed original purchase"
);
if let Some(offer) = &record.offer {
anyhow::ensure!(saved.offer()? == *offer, "Seller changed original offer");
}
Ok(saved)
}
pub(super) async fn ensure_onchain_allows_other_rail(
&self,
buyer: &str,
seller: &str,
content: &str,
) -> Result<()> {
anyhow::ensure!(
Journal::find_for(&self.config.data_dir, buyer, seller, content)?.is_none(),
"An original on-chain purchase remains recoverable; do not pay again or switch methods"
);
Ok(())
}
pub(super) async fn handle_onchain_operation(
&self,
params: Option<Value>,
action: &str,
) -> Result<Value> {
let params: Params = serde_json::from_value(params.context("Missing on-chain operation")?)?;
anyhow::ensure!(
!params.content_id.starts_with("registered_"),
"Registered rentals require their native purchase contract"
);
let peer =
crate::federation::load_unique_payment_peer(&self.config.data_dir, &params.onion)
.await?;
let buyer =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?
.did_key()?;
anyhow::ensure!(buyer != peer.did, "Cannot buy from this same node");
let _admission = crate::content_payment_admission::lock(
&self.config.data_dir,
&buyer,
&peer.did,
&params.content_id,
)
.await?;
let original = if let Some(id) = &params.operation_id {
let journal = Journal::open(&self.config.data_dir, id).await?;
let original = journal.load()?;
if let Some(record) = &original {
anyhow::ensure!(
record.binding.buyer_did == buyer
&& record.binding.seller_did == peer.did
&& record.binding.content_id == params.content_id,
"Original on-chain operation belongs to another purchase"
);
}
original
} else {
Journal::find_for(&self.config.data_dir, &buyer, &peer.did, &params.content_id)?
};
if action == "lookup" {
return Ok(json!({"attempt":original.as_ref().map(public).transpose()?}));
}
if let Some(id) = &params.operation_id {
anyhow::ensure!(
original.as_ref().is_some_and(|r| &r.binding.id == id),
"Original on-chain operation changed"
);
}
let mut record = if let Some(record) = original {
record
} else {
anyhow::ensure!(
matches!(action, "create" | "expose") && params.operation_id.is_none(),
"Recover original on-chain operation first"
);
self.ensure_invoice_allows_other_rail(&buyer, &peer.did, &params.content_id)
.await?;
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
anyhow::ensure!(
cashu
.find_buyers(&buyer, &peer.did, &params.content_id)
.await?
.iter()
.all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
"Recover or cancel original Cashu purchase first"
);
Record::new(
Binding {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer,
seller_did: peer.did.clone(),
content_id: params.content_id.clone(),
price_sats: params.price_sats.context("Expected price required")?,
},
params.onion.clone(),
)?
};
anyhow::ensure!(
record.seller_onion == params.onion
&& params
.price_sats
.is_none_or(|p| p == record.binding.price_sats),
"Original payment address or price changed"
);
let journal = Journal::open(&self.config.data_dir, &record.binding.id).await?;
if journal.load()?.is_none() {
journal.save(&record)?;
}
if record.retirement.is_some() {
return public(&record);
}
if action == "cancel" {
anyhow::ensure!(params.operation_id.is_some() && record.can_retire_unallocated(),"An allocated or mutated on-chain purchase cannot be canceled; recover its original payment");
}
if matches!(action, "create" | "expose" | "prepare" | "pay") && !record.settled {
// Recheck while the same admission guard is held, including resumes
// from another window and records predating this owner flow.
self.ensure_invoice_allows_other_rail(
&record.binding.buyer_did,
&peer.did,
&params.content_id,
)
.await?;
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
anyhow::ensure!(
cashu
.find_buyers(&record.binding.buyer_did, &peer.did, &params.content_id)
.await?
.iter()
.all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
"Another saved Cashu liability must be recovered before on-chain dispatch"
);
}
if action == "prepare" {
anyhow::ensure!(
params.operation_id.is_some(),
"Original operation ID required"
);
if record.template.is_none() && record.plan.is_none() {
let max_fee_sats = params
.max_fee_sats
.context("Explicit maximum fee required")?;
anyhow::ensure!(
(1..=2_100_000_000_000_000).contains(&max_fee_sats),
"Invalid maximum fee"
);
if let Some(rate) = params.sat_per_vbyte {
anyhow::ensure!((1..=5000).contains(&rate), "Invalid fee rate");
}
let wallet = self.onchain_purchase_wallet().await?;
let change = wallet.prepare_change(&journal).await?;
record = wallet
.prepare_plan(
&journal,
super::lnd::onchain_purchase::PlanRequest {
change_address: change,
max_fee_sats,
sat_per_vbyte: params.sat_per_vbyte,
},
)
.await?;
}
return public(&record);
}
if action == "pay" {
anyhow::ensure!(
params.operation_id.is_some(),
"Original operation ID required"
);
if record.settled {
return public(&record);
}
if let Some(plan) = &record.plan {
anyhow::ensure!(
params.plan_sha256.as_deref() == Some(plan.hash()?.as_str()),
"Confirm the original saved funding plan before payment"
);
} else {
let template = record
.template
.as_ref()
.context("Review the original fee first")?;
anyhow::ensure!(
params.template_sha256.as_deref()
== Some(
hex::encode(Sha256::digest(template.psbt_base64.as_bytes())).as_str()
),
"Confirm the original saved transaction before payment"
);
}
let wallet = self.onchain_purchase_wallet().await?;
if record.plan.is_some() && record.quote.is_none() {
record = engine::lease_plan(&journal, &wallet).await?;
engine::mark_address_allocation(&journal, false)?;
let status = self
.request_onchain_allocation(
&record,
peer.fips_npub
.as_deref()
.context("Seller has no authenticated mesh connection")?,
)
.await?;
let quote = status
.quote()?
.context("Original seller allocation is unresolved; recover this operation")?;
record = engine::accept_quote(&journal, quote)?;
}
if record.plan.is_some() && record.template.is_none() {
record = engine::bind_plan(&journal)?;
}
if matches!(
record.phase,
Phase::TemplatePrepared | Phase::LeaseDispatched
) {
record = engine::drive(&journal, &wallet, engine::Action::Lease, None).await?;
}
if matches!(record.phase, Phase::Funded | Phase::SigningDispatched) {
record = engine::drive(&journal, &wallet, engine::Action::Sign, None).await?;
}
if matches!(
record.phase,
Phase::Signed | Phase::BroadcastDispatched | Phase::Published
) {
record = engine::drive(&journal, &wallet, engine::Action::Publish, None).await?;
}
return public(&record);
}
anyhow::ensure!(
matches!(
action,
"create" | "status" | "expose" | "download" | "cancel"
),
"Unsupported on-chain action"
);
let fips = peer
.fips_npub
.context("Seller has no authenticated mesh connection")?;
if action == "expose" && record.offer.is_some() && record.quote.is_none() {
engine::mark_address_allocation(&journal, true)?;
let status = self.request_onchain_allocation(&record, &fips).await?;
record = engine::accept_quote(
&journal,
status
.quote()?
.context("Original seller allocation is unresolved; recover this operation")?,
)?;
}
let remote_action = if action == "create" || action == "expose" && record.offer.is_none() {
"offer"
} else if action == "expose" {
"status"
} else {
action
};
let operation = crate::api::handler::onchain_purchase::Operation {
binding: record.binding.clone(),
action: remote_action.into(),
};
let route = crate::api::handler::onchain_purchase::ROUTE;
let remote = crate::fips::dial::PeerRequest::new(Some(&fips), &params.onion, route)
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(if action == "download" {
900
} else {
45
}))
.send_content_json(&self.config.data_dir, &peer.did, &operation)
.await;
let (mut response, _) = match remote {
Ok(value) => value,
Err(_) => {
return Ok(
json!({"attempt":public(&record)?,"recovery_required":true,"error":"Original on-chain request is saved. Recover this operation; do not request another address or pay again."}),
)
}
};
anyhow::ensure!(
response.status().is_success(),
"Seller could not recover original on-chain purchase {}; retain it",
record.binding.id
);
if action == "download" {
let source = record
.quote
.as_ref()
.context("Recover original address first")?
.source
.clone();
anyhow::ensure!(
response.content_length() == Some(source.size),
"Original file length changed"
);
record.settled = true;
journal.save(&record)?;
let stream = crate::content_purchase_download::verified_stream(
response.bytes_stream(),
source.sha256,
source.size,
);
let owned = crate::content_owned::record_purchase_stream(
&self.config.data_dir,
crate::content_owned::OwnedItem {
onion: params.onion,
content_id: params.content_id,
filename: source.filename,
mime_type: source.mime_type,
size_bytes: source.size,
paid_sats: record.binding.price_sats,
ecash_backend: "onchain".into(),
purchased_at: chrono::Utc::now().to_rfc3339(),
download_complete: false,
},
Box::pin(stream),
Some(source.size),
)
.await?;
return Ok(
json!({"owned":true,"owned_content_id":owned.content_id,"mime_type":owned.mime_type}),
);
}
let mut bytes = vec![];
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
bytes.len() + chunk.len() <= 16384,
"On-chain response too large"
);
bytes.extend_from_slice(&chunk);
}
let body: Value = serde_json::from_slice(&bytes)?;
if body["state"] == "cancelled_unallocated" {
let ack: crate::content_onchain_seller::UnallocatedAck = serde_json::from_value(body)?;
record = engine::retire_unallocated(&journal, ack)?;
return public(&record);
}
anyhow::ensure!(
action != "cancel",
"Seller did not acknowledge unallocated retirement; preserve original operation"
);
let status: crate::content_onchain_seller::Record = serde_json::from_value(body)?;
anyhow::ensure!(
status.binding == record.binding,
"Seller changed original purchase"
);
if record.offer.is_none() && record.quote.is_none() {
record = engine::accept_offer(&journal, status.offer()?)?;
}
if let Some(quote) = status.quote()? {
record = engine::accept_quote(&journal, quote)?;
}
anyhow::ensure!(
!status.paid || record.quote.is_some(),
"Paid purchase lacks original address"
);
record.settled |= status.paid;
journal.save(&record)?;
if action == "expose" && !record.settled {
if record.quote.is_none() {
engine::mark_address_allocation(&journal, true)?;
let status = self.request_onchain_allocation(&record, &fips).await?;
record = engine::accept_quote(
&journal,
status.quote()?.context(
"Original seller allocation is unresolved; recover this operation",
)?,
)?;
}
engine::expose_address(&journal)?;
record = journal.load()?.context("Original record unavailable")?;
}
public(&record)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn binding() -> Binding {
Binding {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap(),
seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([8; 32])).unwrap(),
content_id: "file".into(),
price_sats: 546,
}
}
#[tokio::test]
async fn rental_preserves_unresolved_lightning_operation_on_review_and_delayed_consent() {
let data = tempfile::tempdir().unwrap();
let identity = crate::identity::NodeIdentity::load_or_create(&data.path().join("identity"))
.await
.unwrap();
let mut binding = binding();
binding.buyer_did = identity.did_key().unwrap();
binding.content_id = "registered_rental".into();
let onion = format!("{}.onion", "a".repeat(56));
let peer = serde_json::from_value(json!({
"did": binding.seller_did, "pubkey": hex::encode([8; 32]),
"onion": onion, "trust_level": "trusted", "added_at": "now",
"fips_npub": "fixture-no-network"
}))
.unwrap();
crate::federation::save_nodes(data.path(), &[peer])
.await
.unwrap();
let mut config = crate::config::Config::default();
config.data_dir = data.path().to_path_buf();
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let journal = crate::content_lightning::Journal::open(data.path())
.await
.unwrap();
let record = crate::content_lightning::BuyerRecord {
binding: crate::content_lightning::Binding {
id: binding.id.clone(),
buyer_did: binding.buyer_did.clone(),
seller_did: binding.seller_did.clone(),
content_id: binding.content_id.clone(),
price_sats: 546,
},
seller_onion: onion,
external_exposure: true,
native_retired: false,
native_replacement: None,
native_dispatched: false,
native_result: None,
last: None,
};
journal.save_buyer(&record).unwrap();
drop(journal);
for consent in [
None,
Some(json!({
"operation_id": uuid::Uuid::new_v4().to_string(),
"envelope_sha256": "cd".repeat(32), "wallet_debit_sats": 546
})),
] {
let error = handler
.handle_content_rental_purchase(Some(json!({
"seller_did": binding.seller_did, "content_id": binding.content_id,
"expected_sha256": "ab".repeat(32), "expected_price_sats": 546,
"expected_viewing_seconds": 3600, "max_wallet_debit": 546, "consent": consent
})))
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("externally payable invoice remains unresolved"),
"{error:#}"
);
}
let journal = crate::content_lightning::Journal::open(data.path())
.await
.unwrap();
assert_eq!(
serde_json::to_value(journal.buyer(&binding.id).unwrap().unwrap()).unwrap(),
serde_json::to_value(record).unwrap()
);
assert!(!data.path().join("wallet").exists());
assert!(crate::content_purchase::Journal::open(data.path())
.await
.unwrap()
.find_buyers(&binding.buyer_did, &binding.seller_did, &binding.content_id)
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn rental_waits_for_alternate_rail_commit_and_rejects_quote_and_consent_recovery() {
let data = tempfile::tempdir().unwrap();
let identity = crate::identity::NodeIdentity::load_or_create(&data.path().join("identity"))
.await
.unwrap();
let mut binding = binding();
binding.buyer_did = identity.did_key().unwrap();
binding.content_id = "registered_rental".into();
let onion = format!("{}.onion", "a".repeat(56));
let peer = serde_json::from_value(json!({
"did": binding.seller_did, "pubkey": hex::encode([8; 32]),
"onion": onion, "trust_level": "trusted", "added_at": "now",
"fips_npub": "fixture-no-network"
}))
.unwrap();
crate::federation::save_nodes(data.path(), &[peer])
.await
.unwrap();
let mut config = crate::config::Config::default();
config.data_dir = data.path().to_path_buf();
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
// The other rail owns admission before it persists the uncertain spend.
let admission = crate::content_payment_admission::lock(
data.path(),
&binding.buyer_did,
&binding.seller_did,
&binding.content_id,
)
.await
.unwrap();
let params = json!({
"seller_did": binding.seller_did, "content_id": binding.content_id,
"expected_sha256": "ab".repeat(32), "expected_price_sats": 546,
"expected_viewing_seconds": 3600, "max_wallet_debit": 546
});
let pending = handler.handle_content_rental_purchase(Some(params.clone()));
tokio::pin!(pending);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), &mut pending)
.await
.is_err(),
"Rental must wait for cross-rail admission before inspecting journals/context"
);
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
journal
.save(&Record::new(binding.clone(), onion.clone()).unwrap())
.unwrap();
drop(journal);
let path = data
.path()
.join("content-onchain")
.join(format!("{}.json", binding.id));
let original = std::fs::read(&path).unwrap();
drop(admission);
let error = tokio::time::timeout(std::time::Duration::from_secs(5), &mut pending)
.await
.unwrap()
.unwrap_err();
assert!(
error.to_string().contains("original on-chain purchase"),
"{error:#}"
);
for consent in [
None,
Some(json!({
"operation_id": uuid::Uuid::new_v4().to_string(),
"envelope_sha256": "cd".repeat(32), "wallet_debit_sats": 546
})),
] {
let mut retry = params.clone();
retry["consent"] = consent.unwrap_or(serde_json::Value::Null);
let error = handler
.handle_content_rental_purchase(Some(retry))
.await
.unwrap_err();
assert!(
error.to_string().contains("original on-chain purchase"),
"{error:#}"
);
}
assert_eq!(std::fs::read(path).unwrap(), original);
assert!(!data.path().join("wallet").exists());
assert!(crate::content_purchase::Journal::open(data.path())
.await
.unwrap()
.find_buyers(&binding.buyer_did, &binding.seller_did, &binding.content_id)
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn unresolved_onchain_operation_blocks_cashu_and_every_lightning_spend_entry() {
let data = tempfile::tempdir().unwrap();
let identity = crate::identity::NodeIdentity::load_or_create(&data.path().join("identity"))
.await
.unwrap();
let mut binding = binding();
binding.buyer_did = identity.did_key().unwrap();
let onion = format!("{}.onion", "a".repeat(56));
let peer = serde_json::from_value(json!({
"did": binding.seller_did, "pubkey": hex::encode([8; 32]),
"onion": onion, "trust_level": "trusted", "added_at": "now",
"fips_npub": "fixture-no-network"
}))
.unwrap();
crate::federation::save_nodes(data.path(), &[peer])
.await
.unwrap();
let mut config = crate::config::Config::default();
config.data_dir = data.path().to_path_buf();
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
journal
.save(&Record::new(binding.clone(), onion.clone()).unwrap())
.unwrap();
drop(journal);
let original = std::fs::read(
data.path()
.join("content-onchain")
.join(format!("{}.json", binding.id)),
)
.unwrap();
// Exercise the real RPC entry points, not just the admission helper.
// Each must reject before a Cashu/Lightning journal, offer, invoice or
// wallet dispatch is created, including delayed callbacks and retries.
for consent in [
None,
Some(json!({
"operation_id": uuid::Uuid::new_v4().to_string(),
"envelope_sha256": "ab".repeat(32), "wallet_debit_sats": 546
})),
] {
let error = handler
.handle_content_purchase(Some(json!({
"onion": onion, "content_id": binding.content_id,
"max_wallet_debit": 546, "consent": consent
})))
.await
.unwrap_err();
assert!(
error.to_string().contains("original on-chain purchase"),
"{error:#}"
);
}
for (action, exposure) in [
("create", false),
("pay", false),
("retry", false),
("create", true),
("status", true),
] {
let error = handler
.handle_lightning_operation(
Some(json!({
"onion": onion, "content_id": binding.content_id,
"price_sats": 546, "external_exposure": exposure
})),
action,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("original on-chain purchase"),
"{action}: {error:#}"
);
}
// Read-only recovery lookup remains available despite the blocked rail.
let lookup = handler
.handle_lightning_operation(
Some(json!({
"onion": onion, "content_id": binding.content_id
})),
"lookup",
)
.await
.unwrap();
assert!(lookup["attempt"].is_null());
assert_eq!(
std::fs::read(
data.path()
.join("content-onchain")
.join(format!("{}.json", binding.id))
)
.unwrap(),
original
);
assert!(!data.path().join("wallet").exists());
// No replacement operation has been persisted by any rejected call.
assert!(crate::content_purchase::Journal::open(data.path())
.await
.unwrap()
.find_buyers(&binding.buyer_did, &binding.seller_did, &binding.content_id)
.await
.unwrap()
.is_empty());
assert!(crate::content_lightning::Journal::open(data.path())
.await
.unwrap()
.buyer_for(&binding.buyer_did, &binding.seller_did, &binding.content_id)
.unwrap()
.is_none());
}
#[tokio::test]
async fn buyer_discovery_retains_unresolved_address_and_rejects_duplicate_operations() {
let data = tempfile::tempdir().unwrap();
let binding = binding();
let saved = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap();
let j = Journal::open(data.path(), &binding.id).await.unwrap();
j.save(&saved).unwrap();
drop(j);
let found = Journal::find_for(
data.path(),
&binding.buyer_did,
&binding.seller_did,
&binding.content_id,
)
.unwrap()
.unwrap();
assert_eq!(found.binding.id, binding.id);
assert!(found.blocks_other_rails());
assert!(public(&found).unwrap()["address"].is_null());
assert!(Journal::find_for(
data.path(),
&binding.seller_did,
&binding.buyer_did,
&binding.content_id
)
.unwrap()
.is_none());
let mut second = binding.clone();
second.id = uuid::Uuid::new_v4().to_string();
let j = Journal::open(data.path(), &second.id).await.unwrap();
j.save(&Record::new(second, saved.seller_onion).unwrap())
.unwrap();
drop(j);
assert!(Journal::find_for(
data.path(),
&binding.buyer_did,
&binding.seller_did,
&binding.content_id
)
.is_err());
}
#[tokio::test]
async fn corrupted_node_record_cannot_be_treated_as_permission_to_pay_again() {
let data = tempfile::tempdir().unwrap();
let binding = binding();
let j = Journal::open(data.path(), &binding.id).await.unwrap();
j.save(&Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap())
.unwrap();
drop(j);
std::fs::write(
data.path()
.join("content-onchain")
.join(format!("{}.json", binding.id)),
b"{}",
)
.unwrap();
assert!(Journal::find_for(
data.path(),
&binding.buyer_did,
&binding.seller_did,
&binding.content_id
)
.is_err());
}
#[tokio::test]
async fn only_durable_matching_empty_ack_releases_cross_rail_and_stale_callback_cannot_revive()
{
let data = tempfile::tempdir().unwrap();
let binding = binding();
let _rail = crate::content_payment_admission::lock(
data.path(),
&binding.buyer_did,
&binding.seller_did,
&binding.content_id,
)
.await
.unwrap();
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
let original = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap();
journal.save(&original).unwrap();
let ack = crate::content_onchain_seller::UnallocatedAck {
binding: binding.clone(),
state: "cancelled_unallocated".into(),
address: serde_json::Value::Null,
allocation_dispatched: false,
can_switch_method: true,
};
for wrong in [
crate::content_onchain_seller::UnallocatedAck {
allocation_dispatched: true,
..ack.clone()
},
crate::content_onchain_seller::UnallocatedAck {
address: serde_json::json!("not-empty"),
..ack.clone()
},
crate::content_onchain_seller::UnallocatedAck {
binding: Binding {
id: uuid::Uuid::new_v4().to_string(),
..binding.clone()
},
..ack.clone()
},
] {
assert!(engine::retire_unallocated(&journal, wrong).is_err());
}
assert!(Journal::find_for(
data.path(),
&binding.buyer_did,
&binding.seller_did,
&binding.content_id
)
.unwrap()
.is_some());
let retired = engine::retire_unallocated(&journal, ack).unwrap();
assert!(!retired.blocks_other_rails());
assert!(public(&retired).unwrap()["can_switch_method"] == true);
assert!(journal.save(&original).is_err());
drop(journal);
assert!(Journal::find_for(
data.path(),
&binding.buyer_did,
&binding.seller_did,
&binding.content_id
)
.unwrap()
.is_none());
let mut replacement = binding.clone();
replacement.id = uuid::Uuid::new_v4().to_string();
let journal = Journal::open(data.path(), &replacement.id).await.unwrap();
journal
.save(&Record::new(replacement.clone(), original.seller_onion).unwrap())
.unwrap();
drop(journal);
assert_eq!(
Journal::find_for(
data.path(),
&binding.buyer_did,
&binding.seller_did,
&binding.content_id
)
.unwrap()
.unwrap()
.binding
.id,
replacement.id
);
}
#[tokio::test]
async fn owner_address_is_redacted_until_exposure_is_durable_and_cannot_then_be_retired() {
let data = tempfile::tempdir().unwrap();
let binding = binding();
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
journal
.save(&Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap())
.unwrap();
let mut bytes = vec![0, 20];
bytes.extend([1; 20]);
let address = bitcoin::Address::from_script(
&bitcoin::ScriptBuf::from_bytes(bytes),
bitcoin::Network::Regtest,
)
.unwrap()
.to_string();
let saved = engine::accept_quote(
&journal,
engine::Quote {
binding: binding.clone(),
address: address.clone(),
network: engine::ChainNetwork::Regtest,
source: crate::content_lightning::RetainedFile {
sha256: "a".repeat(64),
size: 4,
filename: "original.txt".into(),
mime_type: "text/plain".into(),
},
},
)
.unwrap();
assert!(public(&saved).unwrap()["address"].is_null());
let ack = crate::content_onchain_seller::UnallocatedAck {
binding: binding.clone(),
state: "cancelled_unallocated".into(),
address: serde_json::Value::Null,
allocation_dispatched: false,
can_switch_method: true,
};
assert!(engine::retire_unallocated(&journal, ack.clone()).is_err());
assert_eq!(engine::expose_address(&journal).unwrap(), address);
drop(journal);
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
let exposed = journal.load().unwrap().unwrap();
assert!(exposed.externally_exposed);
assert_eq!(public(&exposed).unwrap()["address"], address);
assert!(engine::retire_unallocated(&journal, ack).is_err());
assert!(exposed.blocks_other_rails());
}
}
+31 -1
View File
@@ -150,6 +150,7 @@ impl RpcHandler {
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1), "min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(), "currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(), "mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
"payout_address":router.uci_get("tollgate.main.payout_address").unwrap_or_default(),
}) })
} else { } else {
serde_json::json!({ "installed": false }) serde_json::json!({ "installed": false })
@@ -199,10 +200,15 @@ impl RpcHandler {
/// ///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "", /// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1, /// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
/// "mint_url": "<optional override>" }` /// "mint_url": "<optional override>",
/// "payout_address": "<optional Lightning address>" }`
/// ///
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu /// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
/// mint that must be running as an Archy app before calling this endpoint. /// mint that must be running as an Archy app before calling this endpoint.
///
/// `payout_address` sets the "owner" identity's Lightning address for
/// TollGate's own built-in payout (see `config::apply_payout_identity`).
/// Omitted or blank leaves whatever's already on the router untouched.
pub(super) async fn handle_openwrt_provision_tollgate( pub(super) async fn handle_openwrt_provision_tollgate(
&self, &self,
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
@@ -240,12 +246,35 @@ impl RpcHandler {
.unwrap_or_default(); .unwrap_or_default();
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT); let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
// Trim trailing slash(es): tollgate-wrt matches a token's embedded
// mint URL against this value with an exact string compare, and
// Cashu wallets (Minibits included) encode mint URLs without a
// trailing slash. A stray slash here means every otherwise-valid
// token gets rejected as "untrusted mint" — confirmed live against
// archy-x250-pa3 2026-09-07 with a manually-entered
// "https://mint.minibits.cash/Bitcoin/".
let mint_url = p let mint_url = p
.get("mint_url") .get("mint_url")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(&default_mint_url) .unwrap_or(&default_mint_url)
.trim_end_matches('/')
.to_string(); .to_string();
// `None` (not sent, or sent blank) leaves whatever's already on the
// router untouched — see apply_payout_identity's doc comment for why
// that matters (an upstream-default placeholder otherwise survives
// forever, since nothing else ever writes this field).
let payout_address = p
.get("payout_address")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
if let Some(address) = payout_address.as_deref() {
tollgate::config::validate_payout_address(address)
.context("invalid TollGate payout address")?;
}
let config = TollGateConfig { let config = TollGateConfig {
ssid: "archipelago".to_string(), ssid: "archipelago".to_string(),
mint_url, mint_url,
@@ -256,6 +285,7 @@ impl RpcHandler {
.unwrap_or(60_000), .unwrap_or(60_000),
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32, min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true), enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
payout_address,
}; };
// Blocking SSH session, and provision runs `opkg install` over it — // Blocking SSH session, and provision runs `opkg install` over it —
@@ -55,6 +55,7 @@ impl RpcHandler {
.to_string(); .to_string();
super::validation::validate_app_id(&package_id)?; super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?; super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle (prevents double-click // Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package). // queuing two installs on the same package).
@@ -88,6 +89,15 @@ impl RpcHandler {
match handler.handle_package_install(params).await { match handler.handle_package_install(params).await {
Ok(_) => { Ok(_) => {
info!("package.install {}: complete", package_id_spawn); info!("package.install {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::clear_user_uninstalled(&handler.config.data_dir, id)
.await;
}
crate::crash_recovery::mark_installed(
&handler.config.data_dir,
&package_id_spawn,
)
.await;
// The install pipeline has verified the container is up // The install pipeline has verified the container is up
// and healthy (see install.rs post-start exit check). // and healthy (see install.rs post-start exit check).
// Kick the scanner first so the fresh manifest (with // Kick the scanner first so the fresh manifest (with
@@ -183,17 +193,20 @@ impl RpcHandler {
// phase is cleared (None) so no stale InstallPhase // phase is cleared (None) so no stale InstallPhase
// lingers on the card. // lingers on the card.
let err_msg = format!("Install failed: {:#}", e); let err_msg = format!("Install failed: {:#}", e);
let (mut data, _) = handler.state_manager.get_snapshot().await; handler
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) { .state_manager
entry.state = PackageState::Stopped; .mutate_data(|data| {
entry.install_progress = Some(crate::data_model::InstallProgress { if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
size: 0, entry.state = PackageState::Stopped;
downloaded: 0, entry.install_progress = Some(crate::data_model::InstallProgress {
phase: None, size: 0,
message: Some(err_msg), downloaded: 0,
}); phase: None,
handler.state_manager.update_data(data).await; message: Some(err_msg),
} });
}
})
.await;
} }
} }
}); });
@@ -251,6 +264,11 @@ impl RpcHandler {
match handler.handle_package_uninstall(params).await { match handler.handle_package_uninstall(params).await {
Ok(_) => { Ok(_) => {
info!("package.uninstall {}: complete", package_id_spawn); info!("package.uninstall {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::mark_user_uninstalled(&handler.config.data_dir, id)
.await;
crate::crash_recovery::clear_installed(&handler.config.data_dir, id).await;
}
// Inner handler already removed the package entry on // Inner handler already removed the package entry on
// success. Nothing more to do here. // success. Nothing more to do here.
} }
@@ -294,6 +312,12 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))? .ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string(); .to_string();
super::validation::validate_app_id(&package_id)?; super::validation::validate_app_id(&package_id)?;
// Update is stop → pull → remove → recreate, i.e. a fresh start by
// another name: on a disk that shrank since install it would resume
// cuprate's unprunable sync unchecked. Same gate as install and
// start, run BEFORE the Updating flip so a refusal leaves the app
// cleanly in its previous state.
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle. // Reject if already in a transitional lifecycle.
{ {
@@ -323,7 +347,7 @@ impl RpcHandler {
let package_id_spawn = package_id.clone(); let package_id_spawn = package_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
match handler.handle_package_update(params).await { match handler.handle_package_update(params).await {
Ok(_) => { Ok(result) => {
info!("package.update {}: complete", package_id_spawn); info!("package.update {}: complete", package_id_spawn);
// Same reasoning as install: the merge_preserving_transitional // Same reasoning as install: the merge_preserving_transitional
// helper treats Updating as RPC-owned, so we MUST write the // helper treats Updating as RPC-owned, so we MUST write the
@@ -338,19 +362,49 @@ impl RpcHandler {
set_package_state( set_package_state(
&handler.state_manager, &handler.state_manager,
&package_id_spawn, &package_id_spawn,
PackageState::Running, if result.get("status").and_then(|v| v.as_str()) == Some("staged") {
PackageState::Stopped
} else if result.get("status").and_then(|v| v.as_str())
== Some("up-to-date")
{
pre_state.clone().unwrap_or(PackageState::Running)
} else {
PackageState::Running
},
) )
.await; .await;
} }
Err(e) => { Err(e) => {
error!("package.update {} failed: {:#}", package_id_spawn, e); error!("package.update {} failed: {:#}", package_id_spawn, e);
install_log(&format!("UPDATE FAIL: {} — {:#}", package_id_spawn, e)).await; install_log(&format!("UPDATE FAIL: {} — {:#}", package_id_spawn, e)).await;
// Inner handler already ran rollback_update + cleared // Release the transitional overlay before asking the scanner
// update state, but be defensive: revert to pre-state // for real state. Prior Running is not proof of successful
// in case the inner flow died before its cleanup. // rollback, and a failed preflight is not proof of Stopped.
if let Some(prev) = pre_state { handler
set_package_state(&handler.state_manager, &package_id_spawn, prev).await; .state_manager
} .mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
finish_failed_update(entry);
}
data.notifications.retain(|item| {
item.id != format!("update-failed-{package_id_spawn}")
});
data.notifications.push(crate::data_model::Notification {
id: format!("update-failed-{package_id_spawn}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not update {package_id_spawn}"),
message: format!(
"{e}. Runtime recovery does not roll back database changes."
),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id_spawn.clone()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
})
.await;
kick_scanner_and_wait(&handler).await;
} }
} }
}); });
@@ -370,53 +424,16 @@ impl RpcHandler {
/// Matches what the inner handler's `set_install_progress` would do on first /// Matches what the inner handler's `set_install_progress` would do on first
/// call, but fires before the spawn so the UI sees it immediately. /// call, but fires before the spawn so the UI sees it immediately.
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) { async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles}; state_manager
let (mut data, _) = state_manager.get_snapshot().await; .mutate_data(|data| {
let entry = data let entry = data
.package_data .package_data
.entry(package_id.to_string()) .entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry { .or_insert_with(|| super::progress::create_installing_entry(package_id));
state: PackageState::Installing, entry.ui_ready = Some(false);
health: None, entry.state = PackageState::Installing;
exit_code: None, })
static_files: StaticFiles { .await;
license: String::new(),
instructions: String::new(),
// Leave icon empty during the transient Installing window:
// hardcoding `<id>.png` is wrong for ~half our apps (many use
// `.svg` / `.webp`), producing a broken-image flicker until
// the scanner refreshes the entry. The frontend's `icon`
// computed falls through to `curatedMap.get(id)?.icon` which
// has the correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
title: package_id.to_string(),
version: String::new(),
description: Description {
short: "Installing...".to_string(),
long: String::new(),
},
release_notes: String::new(),
license: String::new(),
wrapper_repo: String::new(),
upstream_repo: String::new(),
support_site: String::new(),
marketing_site: String::new(),
donation_url: None,
author: None,
website: None,
interfaces: None,
tier: None,
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
});
entry.state = PackageState::Installing;
state_manager.update_data(data).await;
} }
/// True when the failed install still has a real footprint: any container /// True when the failed install still has a real footprint: any container
@@ -474,20 +491,23 @@ async fn remove_entry_with_notification(
id_prefix: &str, id_prefix: &str,
message: &str, message: &str,
) { ) {
let (mut data, _) = handler.state_manager.get_snapshot().await; handler
data.package_data.remove(package_id); .state_manager
data.notifications.push(crate::data_model::Notification { .mutate_data(|data| {
id: format!("{id_prefix}-{package_id}"), data.package_data.remove(package_id);
level: crate::data_model::NotificationLevel::Error, data.notifications.push(crate::data_model::Notification {
title: format!("Could not install {package_id}"), id: format!("{id_prefix}-{package_id}"),
message: message.to_string(), level: crate::data_model::NotificationLevel::Error,
timestamp: chrono::Utc::now().to_rfc3339(), title: format!("Could not install {package_id}"),
app_id: Some(package_id.to_string()), message: message.to_string(),
}); timestamp: chrono::Utc::now().to_rfc3339(),
while data.notifications.len() > 20 { app_id: Some(package_id.to_string()),
data.notifications.remove(0); });
} while data.notifications.len() > 20 {
handler.state_manager.update_data(data).await; data.notifications.remove(0);
}
})
.await;
} }
/// Flip an existing entry's state and return the pre-flip value (or None if /// Flip an existing entry's state and return the pre-flip value (or None if
@@ -497,18 +517,23 @@ async fn flip_package_state(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) -> Option<PackageState> { ) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
let prev = data.package_data.get(package_id).map(|e| e.state.clone()); .mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) { let prev = data.package_data.get(package_id).map(|e| e.state.clone());
entry.state = new_state; if let Some(entry) = data.package_data.get_mut(package_id) {
state_manager.update_data(data).await; if new_state != PackageState::Running {
} else { entry.ui_ready = Some(false);
warn!( }
"flip_package_state: no entry for {} — cannot flip", entry.state = new_state;
package_id } else {
); warn!(
} "flip_package_state: no entry for {} — cannot flip",
prev package_id
);
}
prev
})
.await
} }
/// Set state unconditionally (no-op if entry no longer exists). /// Set state unconditionally (no-op if entry no longer exists).
@@ -517,13 +542,18 @@ async fn set_package_state(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) { ) {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
if let Some(entry) = data.package_data.get_mut(package_id) { .mutate_data(|data| {
if entry.state != new_state { if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state; if entry.state != new_state {
state_manager.update_data(data).await; if new_state != PackageState::Running {
} entry.ui_ready = Some(false);
} }
entry.state = new_state;
}
}
})
.await
} }
/// Set state and clear the uninstall_stage label. Used when an uninstall /// Set state and clear the uninstall_stage label. Used when an uninstall
@@ -534,12 +564,17 @@ async fn set_package_state_and_clear_uninstall_stage(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) { ) {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
if let Some(entry) = data.package_data.get_mut(package_id) { .mutate_data(|data| {
entry.state = new_state; if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = None; if new_state != PackageState::Running {
state_manager.update_data(data).await; entry.ui_ready = Some(false);
} }
entry.state = new_state;
entry.uninstall_stage = None;
}
})
.await
} }
/// Kick the container scanner to run immediately and wait for it to finish /// Kick the container scanner to run immediately and wait for it to finish
@@ -572,3 +607,104 @@ async fn kick_scanner_and_wait(handler: &RpcHandler) {
}) })
.await; .await;
} }
fn finish_failed_update(entry: &mut crate::data_model::PackageDataEntry) {
if entry.state == PackageState::Updating {
entry.state = PackageState::Installed;
}
entry.install_progress = None;
}
#[cfg(test)]
mod update_completion_tests {
use super::*;
#[tokio::test]
async fn update_image_byte_progress_preserves_owner_and_failure_releases_retry_gate() {
let directory = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = directory.path().to_path_buf();
let state = Arc::new(StateManager::new());
let handler = RpcHandler::new(
config,
Arc::clone(&state),
Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(directory.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
state
.mutate_data(|data| {
let mut entry = super::super::progress::create_installing_entry("movie");
entry.state = PackageState::Running;
entry.ui_ready = Some(true);
data.package_data.insert("movie".into(), entry);
})
.await;
flip_package_state(&state, "movie", PackageState::Updating).await;
let update_ready = state.get_snapshot().await.0.package_data["movie"].ui_ready;
assert_eq!(update_ready, Some(false));
// Both calls happen even when an immutable image already exists locally.
for (downloaded, total) in [(0, 0), (100, 100)] {
handler
.set_install_progress("movie", downloaded, total)
.await;
let (snapshot, _) = state.get_snapshot().await;
let entry = &snapshot.package_data["movie"];
assert_eq!(entry.state, PackageState::Updating);
assert_eq!(entry.ui_ready, update_ready);
assert_eq!(
entry.install_progress.as_ref().unwrap().downloaded,
downloaded
);
}
RpcHandler::update_install_progress(&state, "movie", 50, 100).await;
handler
.set_install_phase("movie", crate::data_model::InstallPhase::Preparing)
.await;
handler
.set_install_message("movie", "Checking original services")
.await;
assert_eq!(
state.get_snapshot().await.0.package_data["movie"].state,
PackageState::Updating
);
state
.mutate_data(|data| finish_failed_update(data.package_data.get_mut("movie").unwrap()))
.await;
let (snapshot, _) = state.get_snapshot().await;
let entry = &snapshot.package_data["movie"];
assert_eq!(
entry.state,
PackageState::Installed,
"Failed update must release RPC transition ownership for scanner/retry admission"
);
assert!(entry.install_progress.is_none());
handler.set_install_progress("fresh-install", 1, 10).await;
let (snapshot, _) = state.get_snapshot().await;
let entry = &snapshot.package_data["fresh-install"];
assert_eq!(entry.state, PackageState::Installing);
assert_eq!(entry.ui_ready, Some(false));
assert_eq!(entry.install_progress.as_ref().unwrap().downloaded, 1);
}
#[test]
fn failure_releases_spinner_without_inventing_stopped_or_restored_runtime() {
let mut entry = super::super::progress::create_installing_entry("movie");
entry.state = PackageState::Updating;
finish_failed_update(&mut entry);
assert_eq!(entry.state, PackageState::Installed);
assert!(entry.install_progress.is_none());
for actual in [
PackageState::Running,
PackageState::Stopped,
PackageState::Exited,
] {
entry.state = actual.clone();
finish_failed_update(&mut entry);
assert_eq!(
entry.state, actual,
"Fresh scanner evidence must win over old pre-update intent"
);
}
}
}
+45 -10
View File
@@ -526,7 +526,18 @@ pub(in crate::api::rpc) async fn get_containers_for_app(package_id: &str) -> Res
.await .await
.context("podman ps timed out while listing containers")? .context("podman ps timed out while listing containers")?
.context("Failed to list containers")?; .context("Failed to list containers")?;
let stdout = String::from_utf8_lossy(&output.stdout); containers_from_list_output(package_id, &output)
}
fn containers_from_list_output(
package_id: &str,
output: &std::process::Output,
) -> Result<Vec<String>> {
anyhow::ensure!(
output.status.success(),
"podman ps failed while listing containers"
);
let stdout = std::str::from_utf8(&output.stdout).context("Invalid container list response")?;
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect(); let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
let patterns = all_container_names(package_id); let patterns = all_container_names(package_id);
@@ -543,6 +554,32 @@ pub(in crate::api::rpc) async fn get_containers_for_app(package_id: &str) -> Res
mod tests { mod tests {
use super::{all_container_names, get_data_dirs_for_app, get_health_check_args}; use super::{all_container_names, get_data_dirs_for_app, get_health_check_args};
#[test]
fn failed_container_listing_is_not_an_absent_app() {
use std::os::unix::process::ExitStatusExt;
let mut output = std::process::Output {
status: std::process::ExitStatus::from_raw(1 << 8),
stdout: vec![],
stderr: b"store unavailable".to_vec(),
};
assert!(super::containers_from_list_output("node-demo-music", &output).is_err());
output.stdout = b"node-demo-music\n".to_vec();
assert!(super::containers_from_list_output("node-demo-music", &output).is_err());
output.status = std::process::ExitStatus::from_raw(0);
assert_eq!(
super::containers_from_list_output("node-demo-music", &output).unwrap(),
vec!["node-demo-music"]
);
output.stdout.clear();
assert!(
super::containers_from_list_output("node-demo-music", &output)
.unwrap()
.is_empty()
);
output.stdout = vec![0xff];
assert!(super::containers_from_list_output("node-demo-music", &output).is_err());
}
#[test] #[test]
fn bitcoin_variant_container_names_are_precise() { fn bitcoin_variant_container_names_are_precise() {
let core = all_container_names("bitcoin-core"); let core = all_container_names("bitcoin-core");
@@ -985,28 +1022,26 @@ pub(super) async fn get_app_config(
) )
} }
"nginx-proxy-manager" => { "nginx-proxy-manager" => {
let storage = crate::container::npm::resolve_storage().await?;
let admin_port = allocator let admin_port = allocator
.allocate_or_get(app_id, 8081, 81) .allocate_or_get(app_id, 8081, 81)
.await .await
.unwrap_or(8081); .unwrap_or(8081);
let http_port = allocator let http_port = allocator
.allocate_or_get("nginx-proxy-manager-http", 8084, 80) .allocate_or_get("nginx-proxy-manager-http", 8088, 80)
.await .await
.unwrap_or(8084); .unwrap_or(8088);
let https_port = allocator let https_port = allocator
.allocate_or_get("nginx-proxy-manager-https", 8444, 443) .allocate_or_get("nginx-proxy-manager-https", 8444, 443)
.await .await
.unwrap_or(8444); .unwrap_or(8444);
( (
vec![ vec![
format!("{}:81", admin_port), format!("127.0.0.1:{}:81", admin_port),
format!("{}:80", http_port), format!("127.0.0.1:{}:80", http_port),
format!("{}:443", https_port), format!("127.0.0.1:{}:443", https_port),
],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
], ],
storage.bind_mounts(),
vec![], vec![],
None, None,
None, None,

Some files were not shown because too many files have changed in this diff Show More