Compare commits
402
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9ada0f31 | ||
|
|
ae84f62123 | ||
|
|
2a40cb7b23 | ||
|
|
2ed45b1094 | ||
|
|
339243e861 | ||
|
|
8919957d4a | ||
|
|
16ddfa6529 | ||
|
|
85fe925763 | ||
|
|
46b38f9800 | ||
|
|
783d8bfd43 | ||
|
|
d041f498cf | ||
|
|
540f581927 | ||
|
|
16c84c450e | ||
|
|
f50521072d | ||
|
|
16085c723a | ||
|
|
820df9a196 | ||
|
|
940b28dd9b | ||
|
|
2cb1bae5ce | ||
|
|
b537009198 | ||
|
|
44a3334f99 | ||
|
|
ecc8cc80c0 | ||
|
|
31ea755c86 | ||
|
|
1e11c93eb5 | ||
|
|
da3a0d9cfa | ||
|
|
3d289884f8 | ||
|
|
bb933d4091 | ||
|
|
84674ffdaf | ||
|
|
152ac785b3 | ||
|
|
45c211f3fd | ||
|
|
ee3380eed9 | ||
|
|
ff283cd895 | ||
|
|
3ab4162a8b | ||
|
|
aff408cc07 | ||
|
|
1988a68e67 | ||
|
|
c9e13ce143 | ||
|
|
f6fd002981 | ||
|
|
b6eb14b13f | ||
|
|
8e401b80a0 | ||
|
|
cc0a88ed9f | ||
|
|
94899e69d3 | ||
|
|
ce73552b59 | ||
|
|
1b0b119a09 | ||
|
|
7fe6335583 | ||
|
|
8d17597202 | ||
|
|
40c3e2cd8b | ||
|
|
6297c3733b | ||
|
|
23f73519d3 | ||
|
|
768e828246 | ||
|
|
39ad3144f1 | ||
|
|
bc8577db0f | ||
|
|
79437d73e8 | ||
|
|
b0395ce9cf | ||
|
|
eef17eb79b | ||
|
|
84f3bd22c7 | ||
|
|
b1df79cad0 | ||
|
|
0ff95251f5 | ||
|
|
82367dc1d5 | ||
|
|
02691b7d0f | ||
|
|
e5d77916d4 | ||
|
|
5828df5658 | ||
|
|
7f03994e97 | ||
|
|
fe398df8f6 | ||
|
|
0df423a84f | ||
|
|
08bffdb43d | ||
|
|
4660a81d51 | ||
|
|
d63c90cb5f | ||
|
|
779d4d66e1 | ||
|
|
cea4fa1a5a | ||
|
|
92d2855bff | ||
|
|
5e737fac6c | ||
|
|
28a92fcc9b | ||
|
|
e959d0eda2 | ||
|
|
799cbe1bc9 | ||
|
|
6e3fea0abb | ||
|
|
fde5a5c907 | ||
|
|
dd097b952c | ||
|
|
58ff04f782 | ||
|
|
6afb6abccf | ||
|
|
8d70d0312c | ||
|
|
daea20bcb2 | ||
|
|
dca32b078b | ||
|
|
cf3c38bed0 | ||
|
|
a28c61af69 | ||
|
|
a1bc642615 | ||
|
|
214cebfac2 | ||
|
|
83632c2439 | ||
|
|
8a70232f18 | ||
|
|
ccfa91aa57 | ||
|
|
b29d58213f | ||
|
|
bd9f00ecbf | ||
|
|
c83037c04e | ||
|
|
0008f48988 | ||
|
|
63e21bb102 | ||
|
|
2ca50ae36c | ||
|
|
b2ecd940de | ||
|
|
05e999b117 | ||
|
|
45032d3e47 | ||
|
|
96259f0e35 | ||
|
|
d5b73fc4e9 | ||
|
|
7c086a5615 | ||
|
|
8c2828716f | ||
|
|
6a342f665c | ||
|
|
8ae0bdea6a | ||
|
|
228e08fdf8 | ||
|
|
884322069a | ||
|
|
1dbca84485 | ||
|
|
06a92f1f61 | ||
|
|
1684d7901e | ||
|
|
eefb374978 | ||
|
|
1a409900d9 | ||
|
|
f9661946ab | ||
|
|
8210544f74 | ||
|
|
d67f5fe0a3 | ||
|
|
06f74f1623 | ||
|
|
66c7a22d04 | ||
|
|
361ba45fe8 | ||
|
|
105454bd61 | ||
|
|
2b2fd2b5b7 | ||
|
|
54f98cbfd2 | ||
|
|
260e13273d | ||
|
|
c58d1180e7 | ||
|
|
bca8bad822 | ||
|
|
eabc0d93fe | ||
|
|
dc84a8b650 | ||
|
|
f78ee25258 | ||
|
|
838ad745f3 | ||
|
|
884ea49238 | ||
|
|
902333e336 | ||
|
|
32317236d9 | ||
|
|
f42f32980d | ||
|
|
23298758f4 | ||
|
|
07c7eb0f14 | ||
|
|
72df1d17aa | ||
|
|
9964b5c158 | ||
|
|
a30c12193d | ||
|
|
470d4f3944 | ||
|
|
c409fd1fe5 | ||
|
|
125a044a75 | ||
|
|
ff5220869c | ||
|
|
ed94a46b07 | ||
|
|
b03d3c890d | ||
|
|
fd98b38364 | ||
|
|
f5a1806ae3 | ||
|
|
07dd1d545c | ||
|
|
9268930c10 | ||
|
|
c7bf4db085 | ||
|
|
1e4a7460ce | ||
|
|
d62769067d | ||
|
|
3dacf217a0 | ||
|
|
4dde14bf5f | ||
|
|
41dc66574d | ||
|
|
7877300617 | ||
|
|
d23da226a0 | ||
|
|
044ecdd0f6 | ||
|
|
2ccc0381bc | ||
|
|
01a55d2de7 | ||
|
|
c47d9d7c14 | ||
|
|
ae01637dfa | ||
|
|
431b904ee9 | ||
|
|
dfbc56402f | ||
|
|
516941192d | ||
|
|
60bd728a04 | ||
|
|
b0b95810e0 | ||
|
|
d19124f5dc | ||
|
|
0c1d1b5796 | ||
|
|
80ebf75313 | ||
|
|
b15f0dc9ea | ||
|
|
7383d47bad | ||
|
|
4ead8376e7 | ||
|
|
d8f5145ff3 | ||
|
|
dc977fe0bd | ||
|
|
18b087202d | ||
|
|
65d265f267 | ||
|
|
9244bcef15 | ||
|
|
2bfa84efa9 | ||
|
|
cade9cb389 | ||
|
|
fd3be7207b | ||
|
|
235f6d04d5 | ||
|
|
b9c75b2141 | ||
|
|
f81cc4ecdb | ||
|
|
573a58622f | ||
|
|
d4f3cceb52 | ||
|
|
7fb7ee80f2 | ||
|
|
68082cec49 | ||
|
|
7e1eb65f3b | ||
|
|
a9a1975163 | ||
|
|
ce3ec96528 | ||
|
|
0d8decb366 | ||
|
|
4d938cd5aa | ||
|
|
3f96be2c0a | ||
|
|
ef6c10f06e | ||
|
|
b50bf6159a | ||
|
|
c57119e9a7 | ||
|
|
beb0dbc1f4 | ||
|
|
fe820e8c4d | ||
|
|
7e43f673a0 | ||
|
|
6e38d0c093 | ||
|
|
973dbeb449 | ||
|
|
6547ae05fa | ||
|
|
558f097fd6 | ||
|
|
a64dd77efa | ||
|
|
0338a193db | ||
|
|
c4b22628af | ||
|
|
808695d715 | ||
|
|
76d4c5c9a2 | ||
|
|
910ed151f1 | ||
|
|
2512a9f62b | ||
|
|
541f073a2e | ||
|
|
39852ab381 | ||
|
|
5a9aac18ea | ||
|
|
f79ecd11ae | ||
|
|
46fdc2764c | ||
|
|
6d450caebf | ||
|
|
49232fd547 | ||
|
|
6e7ea8b9d6 | ||
|
|
6c030a8109 | ||
|
|
68eeb6396d | ||
|
|
fdc596854e | ||
|
|
c2c4d9151c | ||
|
|
45579e53c7 | ||
|
|
1bbf85e0d4 | ||
|
|
8389326c97 | ||
|
|
a5304518c8 | ||
|
|
ba1de69fc5 | ||
|
|
cffb74326a | ||
|
|
f1fb388ded | ||
|
|
30b5975534 | ||
|
|
687ec881ca | ||
|
|
fba3273c67 | ||
|
|
ed96df0ac3 | ||
|
|
697aabeec3 | ||
|
|
40fd91b9e1 | ||
|
|
fdee8658c3 | ||
|
|
58a0c6ef64 | ||
|
|
13d1b459fc | ||
|
|
49703d7e88 | ||
|
|
e4eae71314 | ||
|
|
47cd915e40 | ||
|
|
530c497277 | ||
|
|
cb79ac5321 | ||
|
|
b52214f7a0 | ||
|
|
a876dc3d0b | ||
|
|
057150d377 | ||
|
|
5d66d2758d | ||
|
|
ee7b9b897a | ||
|
|
d94ff097d2 | ||
|
|
0dda84e5c4 | ||
|
|
abcf77eae3 | ||
|
|
cae0099d6f | ||
|
|
1c6daab92a | ||
|
|
f28c334c72 | ||
|
|
07de8de380 | ||
|
|
e3775771ca | ||
|
|
4228ce443c | ||
|
|
af85d2be53 | ||
|
|
876a069d06 | ||
|
|
96dfed1ec5 | ||
|
|
3211852acd | ||
|
|
048007ea2d | ||
|
|
d4b359dbae | ||
|
|
a4ede20204 | ||
|
|
9c95b8732f | ||
|
|
9f0df2ac44 | ||
|
|
719e723816 | ||
|
|
88d473f6e1 | ||
|
|
c3bfbe8519 | ||
|
|
9771378bfd | ||
|
|
12e2a82b28 | ||
|
|
a7cc7084f2 | ||
|
|
2a2ae7da02 | ||
|
|
e8683aa5b1 | ||
|
|
a49d4d7128 | ||
|
|
a3f0bf0af7 | ||
|
|
e844bbe1c7 | ||
|
|
08c93f4aad | ||
|
|
367c32bb08 | ||
|
|
cc9f02dfd2 | ||
|
|
715e86c901 | ||
|
|
8615e0bc8d | ||
|
|
cc24055d6c | ||
|
|
805e5bcae1 | ||
|
|
6c84a6a771 | ||
|
|
f3264c8de5 | ||
|
|
19207282f9 | ||
|
|
65b51b98f4 | ||
|
|
081c8215c1 | ||
|
|
4b6d102415 | ||
|
|
d46f6ceeeb | ||
|
|
607f54260e | ||
|
|
b984b2a698 | ||
|
|
cb33fe26e4 | ||
|
|
a0cb29744d | ||
|
|
c2d2b00c5c | ||
|
|
ea3367ed45 | ||
|
|
559883b007 | ||
|
|
2e761666d5 | ||
|
|
1971aeb3e3 | ||
|
|
b8e512fed6 | ||
|
|
8ffbf5ff6e | ||
|
|
744923f7d3 | ||
|
|
140f4d91cd | ||
|
|
a9edcd6b3e | ||
|
|
6fba95fe5a | ||
|
|
9ce04627dd | ||
|
|
df7677d23f | ||
|
|
2fee0339cb | ||
|
|
051dc7e3df | ||
|
|
11f016a944 | ||
|
|
c78b6021c3 | ||
|
|
f4fa575fa0 | ||
|
|
6c985b8da3 | ||
|
|
a94b9c64aa | ||
|
|
4e167cbcf8 | ||
|
|
5db0d5acc3 | ||
|
|
69cd4021f2 | ||
|
|
a67cffe88d | ||
|
|
2b04f9a79c | ||
|
|
2fad10c8de | ||
|
|
3fc37642cd | ||
|
|
b2ada09b7c | ||
|
|
883c5a7c76 | ||
|
|
7946ef8636 | ||
|
|
6650ae2ca2 | ||
|
|
631c2bc73a | ||
|
|
57923b0a5f | ||
|
|
febdda968e | ||
|
|
6faebff8ba | ||
|
|
e5b52b84a5 | ||
|
|
5c0b6402ed | ||
|
|
e110dd1c0c | ||
|
|
67a24d6a65 | ||
|
|
908e366006 | ||
|
|
2a2a4552f7 | ||
|
|
a2e6138279 | ||
|
|
aa10bd1247 | ||
|
|
5492080526 | ||
|
|
7e11f78eb4 | ||
|
|
2fa82e4506 | ||
|
|
45b3e48779 | ||
|
|
e54f83df8f | ||
|
|
131c39cf74 | ||
|
|
104e0601ff | ||
|
|
bf7fb425eb | ||
|
|
9af49291e9 | ||
|
|
fefcbfdbc4 | ||
|
|
29668d3adb | ||
|
|
83ba98ab42 | ||
|
|
0c25449566 | ||
|
|
10d31ae13c | ||
|
|
e97f958f45 | ||
|
|
3d0c67eb9b | ||
|
|
6f098cd9c2 | ||
|
|
d849a2f794 | ||
|
|
041f1fa2d3 | ||
|
|
cfd9a596c0 | ||
|
|
eaecab16ca | ||
|
|
9a041bed18 | ||
|
|
053f03be49 | ||
|
|
cedfbb2b07 | ||
|
|
7ae812e3f6 | ||
|
|
bc386965da | ||
|
|
7abd04a7f6 | ||
|
|
441733646f | ||
|
|
ccf823590a | ||
|
|
d9775ac144 | ||
|
|
0925c58821 | ||
|
|
2d27f9c475 | ||
|
|
868e46fac9 | ||
|
|
2aa77d1b7b | ||
|
|
a6b9e7ab49 | ||
|
|
a902cc84fe | ||
|
|
157c9ec055 | ||
|
|
415826f0a6 | ||
|
|
69857c4ace | ||
|
|
e6e46a1427 | ||
|
|
e0b2181ae9 | ||
|
|
446fa7b7fd | ||
|
|
ba8b1f29b2 | ||
|
|
b8266c2872 | ||
|
|
5aa74d0513 | ||
|
|
daac47cac4 | ||
|
|
138a541d01 | ||
|
|
833c939220 | ||
|
|
2c1bcacf0a | ||
|
|
f1d0092e57 | ||
|
|
7dfb0e0013 | ||
|
|
775d7b9877 | ||
|
|
c18ebd7f5b | ||
|
|
494d248356 | ||
|
|
3acefecc24 | ||
|
|
19c49c6605 | ||
|
|
d6e0c142c6 | ||
|
|
57729f8e18 | ||
|
|
4fdadad89d | ||
|
|
f4d3455496 | ||
|
|
227174e541 | ||
|
|
2e72b38778 | ||
|
|
0be7aee49d | ||
|
|
6d5f3ffb85 | ||
|
|
d1bc1273d4 | ||
|
|
96fb5a4f19 | ||
|
|
f91c1f33db |
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: archipelago-app
|
||||
description: Build, package, test, and update a manifest-driven Archipelago app using the real app developer contract, rootless runtime, and lifecycle acceptance flow.
|
||||
metadata:
|
||||
short-description: Build a real Archipelago app
|
||||
---
|
||||
|
||||
# Archipelago app development
|
||||
|
||||
Use this skill when creating or changing an Archipelago app, manifest, container
|
||||
build, app integration, credentials, signer flow, or app preview. Always load
|
||||
`archipelago-design` for newly authored UI.
|
||||
|
||||
Read [app-contract.md](references/app-contract.md),
|
||||
`docs/app-developer-guide.md`, and `docs/app-manifest-spec.md` before coding.
|
||||
|
||||
## Required loop
|
||||
|
||||
1. Create a dedicated worktree or project directory and choose the smallest
|
||||
useful app scope. Prefer the maintained starter and its pinned dependencies.
|
||||
2. Implement the app as a manifest, rootless container, persistent data path,
|
||||
truthful health/readiness check, declared interface, and tests. Never add a
|
||||
per-app Rust installer or rootful/Docker-socket shortcut.
|
||||
3. Use generated secrets or declared secret files; never put credentials in a
|
||||
manifest, image, logs, URL, or frontend bundle. Keep production wallets and
|
||||
app databases outside development fixtures.
|
||||
4. Validate the manifest and build context, then run the app through install,
|
||||
start, stop, restart, manager restart, uninstall with data preservation, and
|
||||
reinstall. Verify the real My Apps/Services launch path, not only a direct
|
||||
port.
|
||||
5. For UI, exercise standalone and embedded modes at 320, 390, 768, and 1440px
|
||||
plus phone landscape, keyboard navigation, loading/empty/error/retry/success,
|
||||
safe areas, and reduced motion.
|
||||
6. Report source, disposable-node, actual-node, and release-artifact evidence
|
||||
separately. Catalog publication is a separate request.
|
||||
|
||||
Important runtime facts:
|
||||
|
||||
- `/opt/archipelago/apps` is rebuilt from the runtime payload at backend start;
|
||||
staging only there will be lost. Follow the developer guide's payload path.
|
||||
- Signed catalog entries take precedence over disk manifests for catalog apps.
|
||||
- Installed does not mean ready or launchable; use health and readiness evidence.
|
||||
- App interfaces describe the service behind the gate. Do not hard-code a node
|
||||
IP, scheme, app path, or host frame URL into app code.
|
||||
|
||||
## References
|
||||
|
||||
- [app contract and acceptance](references/app-contract.md)
|
||||
- [design routing](../archipelago-design/SKILL.md)
|
||||
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "Archipelago app"
|
||||
short_description: "Build a real Archipelago app"
|
||||
brand_color: "#F7931A"
|
||||
default_prompt: "Use $archipelago-app to build and validate this Archipelago app."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,17 @@
|
||||
# App contract
|
||||
|
||||
Start with `docs/app-developer-guide.md` and `docs/app-manifest-spec.md`; those
|
||||
files are authoritative for fields and launch behavior. Validate with:
|
||||
|
||||
```bash
|
||||
./scripts/validate-app-manifest.sh apps/<id>/manifest.yml
|
||||
python3 scripts/generate-app-catalog.py
|
||||
python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
```
|
||||
|
||||
Use pinned image versions, read-only root, no-new-privileges, minimal
|
||||
capabilities, rootless Podman, declared persistent data under
|
||||
`/var/lib/archipelago/<id>`, health checks, generated/declared secrets, and
|
||||
truthful interfaces. Test install/start/stop/restart/manager-restart/uninstall
|
||||
with data preservation/reinstall on a disposable node. Do not replace the
|
||||
signed catalog to make a local test app appear.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: archipelago-design
|
||||
description: Create or change Archipelago UI using the shared semantic tokens, components, responsive patterns, accessibility states, and embedded/standalone host contract.
|
||||
metadata:
|
||||
short-description: Keep Archipelago UI consistent
|
||||
---
|
||||
|
||||
# Archipelago design system
|
||||
|
||||
Use this skill for any new or changed Archipelago UI, including app screens,
|
||||
Terminal, setup flows, dialogs, dashboards, and app wrappers. Read
|
||||
[design-contract.md](references/design-contract.md) before implementation.
|
||||
|
||||
## Rules
|
||||
|
||||
- Find and reuse the closest shared component and pattern before creating one.
|
||||
- Use semantic `--archy-*` tokens and the pinned kit version. A bespoke color,
|
||||
font, radius, shadow, or z-index needs a documented reason.
|
||||
- Preserve the dark baseline, readable surfaces, 4px spacing rhythm, bottom
|
||||
action placement, 44px touch targets, visible focus, and safe-area behavior.
|
||||
- Keep terminal/editor/tmux keys inside the terminal focus boundary; generic
|
||||
modal Escape/arrow handlers must not consume them.
|
||||
- Implement loading, empty, offline, denied, validation-error, retry, disabled,
|
||||
and confirmed-success states. Status color must have text or icon support.
|
||||
- Test standalone and embedded modes. Embedded apps do not duplicate the host
|
||||
wallpaper or navigation and must work without a host handshake.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the reference screen and source; choose the matching component/pattern.
|
||||
2. Build with the shared kit and local assets, not runtime dashboard CSS or CDN
|
||||
fonts. Keep host RPC, signer, and credential bridges behind typed adapters.
|
||||
3. Render the gallery/preview at required viewports and inspect screenshots and
|
||||
keyboard behavior. Check contrast on the real composited surface.
|
||||
4. Record deliberate exceptions and update the kit only when a reusable need is
|
||||
demonstrated. Do not silently accept visual-diff changes.
|
||||
|
||||
## References
|
||||
|
||||
- [design contract](references/design-contract.md)
|
||||
- [existing component map](references/component-map.md)
|
||||
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "Archipelago design"
|
||||
short_description: "Keep Archipelago UI consistent"
|
||||
brand_color: "#F7931A"
|
||||
default_prompt: "Use $archipelago-design to implement this UI in Archipelago's design system."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,15 @@
|
||||
# Existing component map
|
||||
|
||||
Reuse these first:
|
||||
|
||||
- Structure: `BaseModal.vue`, `BackButton.vue`, `EmptyState.vue`,
|
||||
`SkeletonCard.vue`.
|
||||
- Controls: `ToggleSwitch.vue`, `PasswordRevealInput.vue`,
|
||||
`AppSearchField.vue`, `CopyButton.vue`.
|
||||
- Feedback: `ToastStack.vue`, `ContainerStatus.vue`, existing upload/progress
|
||||
components.
|
||||
- Completion: `PaymentSuccessPane.vue`, `IdentitySuccessPane.vue`.
|
||||
- App flows: `AppLauncherOverlay.vue`, `AppCredentialInterstitial.vue`.
|
||||
|
||||
The terminal must have an explicit keyboard ownership boundary and must not be
|
||||
wrapped in the generic arrow-key modal behavior without adapting it.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Design contract
|
||||
|
||||
The current baseline is defined by `neode-ui/src/style.css`,
|
||||
`neode-ui/tailwind.config.js`, `BaseModal.vue`, `AppSearchField.vue`,
|
||||
`EmptyState.vue`, `SkeletonCard.vue`, and `PaymentSuccessPane.vue`. Preserve the
|
||||
dark canvas, glass-card surface, 4px spacing scale, semantic orange accent,
|
||||
local licensed fonts, bottom card actions, 40px desktop/52px mobile search,
|
||||
44px touch targets, visible focus, dynamic viewport, safe-area and embedded
|
||||
canvas rules while the shared kit is extracted.
|
||||
|
||||
Use semantic tokens, not raw values. New controls must document keyboard,
|
||||
focus, disabled, loading, validation, error, retry, success, responsive, and
|
||||
reduced-motion behavior. No essential action may be hover-only. Use the existing
|
||||
dialog footer/content split and restore focus after close.
|
||||
|
||||
Visual acceptance covers 320/390/768/1440px, phone landscape, enlarged text,
|
||||
standalone/embedded modes, dark native controls, no-blur fallback, and actual
|
||||
Companion WebView behavior where applicable.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: archipelago
|
||||
description: Configure, troubleshoot, and safely modify an Archipelago node or its developer environment using the repository's typed operations, ownership rules, and recovery workflow.
|
||||
metadata:
|
||||
short-description: Work safely on Archipelago systems
|
||||
---
|
||||
|
||||
# Archipelago system work
|
||||
|
||||
Use this skill for node configuration, terminal/developer setup, service
|
||||
diagnosis, network and SSH work, supported app operations, and system changes.
|
||||
For app implementation load `archipelago-app`; for any new or changed UI also
|
||||
load `archipelago-design`.
|
||||
|
||||
Read [system-map.md](references/system-map.md) before acting. Read `AGENTS.md`
|
||||
and the relevant project documentation in the current checkout.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the node, repository/worktree, owner, and whether the request is
|
||||
planning, source work, a disposable test, or a live-node operation.
|
||||
2. Inspect current state before changing it. Prefer `archy`/Archipelago RPC
|
||||
operations and existing scripts over direct edits or ad-hoc service commands.
|
||||
3. Preserve wallets, app data, credentials, user uninstall decisions, and
|
||||
unrelated services. Back up only the scoped state before a mutation.
|
||||
4. Make the smallest reversible change. Keep generated state separate from
|
||||
user overrides; never edit generated or packaged files when an owned source
|
||||
or managed override exists.
|
||||
5. Verify the actual result and report source tests, disposable integration,
|
||||
live-node acceptance, and packaged-artifact checks separately.
|
||||
|
||||
For repository work, use a dedicated worktree and focused branch. For backend
|
||||
unit tests use `scripts/test-backend-isolated.sh`; do not run unrestricted
|
||||
`cargo test` on a node with installed apps. Do not publish OTA, ISO, catalog,
|
||||
or Git mirrors from this skill unless that publication is explicitly requested
|
||||
and every release gate is satisfied.
|
||||
|
||||
## Terminal and sessions
|
||||
|
||||
Treat terminal close as detach. Resume the existing named session; never create
|
||||
a duplicate shell or replay a lost command. A reboot can restore workspace and
|
||||
Codex conversation metadata but cannot restore the old process. Distinguish
|
||||
running, detached, ended, and interrupted states in user-facing output.
|
||||
|
||||
Keep credentials, wallet material, terminal output, and command contents out of
|
||||
diagnostics and support bundles. A terminal is a privileged capability: verify
|
||||
the authenticated owner, origin, attachment grant, and account boundary before
|
||||
any PTY bytes flow.
|
||||
|
||||
## References
|
||||
|
||||
- [system map and safe recipes](references/system-map.md)
|
||||
- [app and design routing](references/routing.md)
|
||||
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "Archipelago system"
|
||||
short_description: "Work safely on Archipelago systems"
|
||||
brand_color: "#F7931A"
|
||||
default_prompt: "Use $archipelago to inspect and safely change this Archipelago node."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,10 @@
|
||||
# Skill routing
|
||||
|
||||
Load `archipelago-app` for manifests, containers, app previews, lifecycle,
|
||||
credentials, signer integration, or app packaging. Load `archipelago-design` for
|
||||
any newly authored or changed UI. For backend-only work, use only the system
|
||||
skill and the relevant project docs.
|
||||
|
||||
Planning stays planning. A source change, node mutation, deployment, or
|
||||
publication requires that explicit scope from the user. A skill supplies
|
||||
workflow knowledge; it does not grant additional privileges.
|
||||
@@ -0,0 +1,19 @@
|
||||
# System map and safe recipes
|
||||
|
||||
The native backend is `core/archipelago`; the Vue dashboard is `neode-ui`; ISO
|
||||
and first-boot material lives under `image-recipe` and `scripts`; app manifests
|
||||
are under `apps/<id>/manifest.yml`. Read `CLAUDE.md`, `AGENTS.md`, and the current
|
||||
release checklist before release work.
|
||||
|
||||
Use the existing typed RPC and orchestration layers for app lifecycle, network,
|
||||
wallet, identity, and service operations. Inspect a matching handler before
|
||||
adding an endpoint. Preserve the existing session cookie, CSRF, Origin, and
|
||||
app-gate protections.
|
||||
|
||||
For source tests, run frontend checks from `neode-ui`. Backend unit tests run
|
||||
only through `scripts/test-backend-isolated.sh`; live host checks must be named
|
||||
and explicit. Do not touch real wallet/payment/channel state for a test fixture.
|
||||
|
||||
Developer changes belong in a dedicated worktree. Keep generated catalog,
|
||||
runtime payload, release artifacts, and local node state separate until the
|
||||
request explicitly includes packaging or deployment.
|
||||
+7
-1
@@ -4,7 +4,13 @@
|
||||
# Allow neode-ui (frontend + mock backend + docker configs)
|
||||
!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/
|
||||
|
||||
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
|
||||
|
||||
@@ -17,6 +17,9 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'aiui/**'
|
||||
- 'scripts/build-aiui.sh'
|
||||
- '.dockerignore'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.gitea/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
@@ -65,10 +68,12 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- 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 }}"
|
||||
|
||||
@@ -17,6 +17,9 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'aiui/**'
|
||||
- 'scripts/build-aiui.sh'
|
||||
- '.dockerignore'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
@@ -65,10 +68,12 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- 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 }}"
|
||||
|
||||
@@ -162,3 +162,6 @@ uploads/
|
||||
|
||||
# Generated PWA dev output (vite-plugin-pwa) — never a source artifact
|
||||
neode-ui/dev-dist/
|
||||
|
||||
# Isolated feature worktree compilation and validation artifacts
|
||||
.build/
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
# 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
|
||||
@@ -29,3 +5,45 @@ 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.
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 52
|
||||
versionName = "0.5.32"
|
||||
versionCode = 57
|
||||
versionName = "0.5.37"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -142,6 +142,9 @@ tasks.matching {
|
||||
}.configureEach { dependsOn("buildRustArm64") }
|
||||
|
||||
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")
|
||||
implementation(composeBom)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<!-- 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_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
@@ -37,11 +38,13 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/Theme.Archipelago.Splash"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -65,6 +68,12 @@
|
||||
</intent-filter>
|
||||
</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),
|
||||
configured entirely by scanning the node's pairing QR. -->
|
||||
<service
|
||||
|
||||
@@ -9,11 +9,18 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
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
|
||||
// later one (launchMode=singleTask). Consumed by AppNavHost.
|
||||
@@ -49,11 +56,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// Swiped out of recents (or otherwise finished) — let go of the
|
||||
// retained kiosk WebView so the next launch starts clean. Without
|
||||
// this the FIPS service keeps the process (and the static WebView)
|
||||
// alive, and "close the app" no longer restarted it. isFinishing
|
||||
// keeps config changes (rotation) on the fast reattach path.
|
||||
if (isFinishing) releaseKioskWebView()
|
||||
// Keep an authorized playing WebView owned by the media service;
|
||||
// discard ordinary dashboard state when the task is finished.
|
||||
if (isFinishing) finishKioskActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
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.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
@@ -252,6 +253,7 @@ private fun MenuPanel(
|
||||
HubPage.FIPS -> "FIPS Mesh"
|
||||
HubPage.BACKUP -> "Backup & Restore"
|
||||
HubPage.SIGNER -> "Remote Signer"
|
||||
HubPage.AUDIO -> "Playback diagnostics"
|
||||
HubPage.HUB -> "Menu"
|
||||
},
|
||||
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||
@@ -307,6 +309,7 @@ private fun MenuPanel(
|
||||
onDismiss()
|
||||
restartCompanionApp(hubContext)
|
||||
}
|
||||
HubCard(Icons.Default.Dashboard, "Playback diagnostics", "Local background audio status") { page = HubPage.AUDIO }
|
||||
val versionLabel = remember {
|
||||
runCatching {
|
||||
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 -> {
|
||||
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. */
|
||||
@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. */
|
||||
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
|
||||
* 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. */
|
||||
@@ -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)
|
||||
|
||||
private object KioskWebView {
|
||||
var backgroundOwned = false
|
||||
var instance: WebView? = null
|
||||
var url: String? = null
|
||||
|
||||
@@ -306,13 +330,19 @@ private object KioskWebView {
|
||||
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
||||
var onQrClose: () -> Unit = {}
|
||||
|
||||
fun clearDelegates() {
|
||||
onRouteOutbound = {}; onOpenInApp = {}; onQrOpen = {}
|
||||
onQrStatus = { _, _ -> }; onQrClose = {}
|
||||
}
|
||||
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.destroy()
|
||||
}
|
||||
instance = null
|
||||
url = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +641,9 @@ fun WebViewScreen(
|
||||
// before surfacing the error page: the mesh tunnel works from anywhere.
|
||||
meshFallbackUrl: String? = null,
|
||||
) {
|
||||
val fullscreen = rememberWebViewFullscreen()
|
||||
val cloudPip = rememberCloudVideoPip(fullscreen)
|
||||
val downloads = rememberWebViewDownloads()
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
// First kiosk load (often over the FIPS mesh) gets the full branded
|
||||
// loader; later navigations keep just the slim top progress bar.
|
||||
@@ -898,7 +931,9 @@ fun WebViewScreen(
|
||||
// stale closures from the previous visit are replaced.
|
||||
if (KioskWebView.url != serverUrl) KioskWebView.drop()
|
||||
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)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -913,6 +948,9 @@ fun WebViewScreen(
|
||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||
|
||||
applyArchipelagoSettings()
|
||||
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
||||
CompanionAudioBridge.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
||||
setDownloadListener(downloads)
|
||||
settings.apply {
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
@@ -1086,6 +1124,8 @@ fun WebViewScreen(
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
CompanionAudioBridge.release(view ?: return)
|
||||
cloudPip.reset()
|
||||
isLoading = true
|
||||
hasError = false
|
||||
// New document — the injected safe-area style is
|
||||
@@ -1181,6 +1221,12 @@ fun WebViewScreen(
|
||||
}
|
||||
|
||||
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) {
|
||||
loadProgress = newProgress
|
||||
}
|
||||
@@ -1546,6 +1592,8 @@ private fun InAppBrowser(
|
||||
appName: String? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val fullscreen = rememberWebViewFullscreen()
|
||||
val downloads = rememberWebViewDownloads()
|
||||
val context = LocalContext.current
|
||||
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
|
||||
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
|
||||
@@ -1643,6 +1691,7 @@ private fun InAppBrowser(
|
||||
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
applyArchipelagoSettings()
|
||||
setDownloadListener(downloads)
|
||||
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
||||
// served over plain HTTP too — same dead-clipboard trap.
|
||||
addClipboardBridge()
|
||||
@@ -1662,6 +1711,12 @@ private fun InAppBrowser(
|
||||
)
|
||||
|
||||
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) {
|
||||
progress = newProgress
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,38 @@
|
||||
# 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.
|
||||
|
||||
+38
-1
@@ -64,12 +64,49 @@ App submissions must:
|
||||
|
||||
## 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.
|
||||
3. Include screenshots for UI changes.
|
||||
4. Link relevant issues or docs.
|
||||
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:
|
||||
|
||||
```text
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Archipelago
|
||||
|
||||
> **Alpha testing — funds at your own risk.** Archipelago is experimental software and is not production-ready. Any funds you put on it are at your own risk. Substantial security hardening is planned before production readiness; current builds are for testing and feedback.
|
||||
|
||||
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
|
||||
|
||||
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
|
||||
@@ -11,14 +13,19 @@ Podman containers managed by the Rust backend.
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://source.archipelago-foundation.org/lfg2025/archy/releases)
|
||||
[](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:
|
||||
The latest published pre-release is **v1.9.0-alpha**. Download the
|
||||
[x86_64 server installer ISO](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.9.0-alpha/archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso)
|
||||
or read the [release notes and known limitations](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.9.0-alpha).
|
||||
Newer changes on `main` and private UAT deployments are not included in that
|
||||
published ISO. Check the [releases page](https://source.archipelago-foundation.org/lfg2025/archy/releases)
|
||||
for subsequent published installers and signed OTA artifacts.
|
||||
|
||||
**ngit is the canonical source contribution and review platform.** Gitea mirrors
|
||||
accepted source and hosts release downloads. For Nostr-native cloning:
|
||||
|
||||
```
|
||||
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
|
||||
@@ -27,6 +34,50 @@ nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ng
|
||||
Clone with ngit, or use the Gitea mirror when you need a conventional Git
|
||||
remote. Contributions should follow [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Install on a server
|
||||
|
||||
Use a dedicated **x86_64 Intel/AMD server, mini PC, or PC**, an SSD, and an
|
||||
8 GB or larger USB drive. For Bitcoin and multiple apps, 8 GB or more RAM and
|
||||
a generously sized SSD are recommended; an unpruned Bitcoin node needs room
|
||||
for the growing blockchain. Connect Ethernet and a monitor/keyboard, or use
|
||||
your server's remote console and virtual installation media.
|
||||
|
||||
1. **Download the installer and checksum:**
|
||||
- [Archipelago 1.9.0-alpha ISO](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.9.0-alpha/archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso) (approximately 2.7 GB).
|
||||
- [SHA-256 checksum](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.9.0-alpha/archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso.sha256).
|
||||
- [Signed checksum JSON](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.9.0-alpha/archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso.sha256.json).
|
||||
2. **Verify the download.** Save the ISO and `.sha256` file together, then on
|
||||
Linux run:
|
||||
|
||||
```bash
|
||||
sha256sum --check archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso.sha256
|
||||
```
|
||||
|
||||
The result must be `OK`. This checks file integrity; the signed JSON is
|
||||
provided separately for release-signature verification.
|
||||
3. **Write the ISO to USB** using [Balena Etcher](https://etcher.balena.io/) or
|
||||
your preferred image writer. Write the image rather than copying the ISO
|
||||
into the USB filesystem. For a remotely managed server, attach the ISO as
|
||||
virtual installation media instead. This is a raw `.iso`; no decompression
|
||||
is needed.
|
||||
4. **Boot the server from that media.** Disable Secure Boot for this installer
|
||||
and follow the console installation prompts. **Installation erases the
|
||||
selected target disk**, so back up its contents and check the disk selection
|
||||
carefully.
|
||||
5. **Remove/eject the installation media and boot from the installed disk.**
|
||||
Find the server's address in your router's DHCP client list or local console,
|
||||
then open `http://<server-ip>` from another device on the same network.
|
||||
6. **Complete first-run setup:** create your dashboard password and follow the
|
||||
onboarding wizard to choose apps and Bitcoin storage settings. The unbundled
|
||||
ISO downloads app images as needed, so allow internet access for app setup.
|
||||
|
||||
For an existing Archipelago server, use the dashboard's **Settings** update
|
||||
flow for a published OTA rather than reinstalling. See the
|
||||
[user walkthrough](docs/user-walkthrough.md) for onboarding and daily use.
|
||||
|
||||
**This remains alpha software: funds are at your own risk, and production
|
||||
security hardening is still ahead.**
|
||||
|
||||
## What is here
|
||||
|
||||
- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -61,6 +61,12 @@ function mockClaudeResponse(events: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
it('starts on Routstr before any saved provider selection', () => {
|
||||
setActivePinia(createPinia())
|
||||
const { activeProvider } = useAI()
|
||||
expect(activeProvider.value).toBe('routstr')
|
||||
})
|
||||
|
||||
describe('useAI', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -74,11 +80,6 @@ describe('useAI', () => {
|
||||
})
|
||||
|
||||
describe('provider selection', () => {
|
||||
it('defaults to claude provider', () => {
|
||||
const { activeProvider } = useAI()
|
||||
expect(activeProvider.value).toBe('claude')
|
||||
})
|
||||
|
||||
it('switches provider via setProvider', () => {
|
||||
const { setProvider, activeProvider, activeModel } = useAI()
|
||||
setProvider('openrouter')
|
||||
@@ -249,6 +250,24 @@ describe('useAI', () => {
|
||||
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 () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure'))
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
:style="modelPickerDropdownStyle"
|
||||
@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">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
|
||||
{{ provider.name }}
|
||||
@@ -247,6 +248,7 @@ import { useAI } from '@/composables/useAI'
|
||||
import { useContentPanel } from '@/composables/useContentPanel'
|
||||
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
|
||||
import { parseImportFile } from '@/utils/conversation-import'
|
||||
import { archyBridge } from '@/services/archyBridge'
|
||||
import { useComparisonMode } from '@/composables/useComparisonMode'
|
||||
|
||||
defineProps<{
|
||||
@@ -332,8 +334,7 @@ const modelDisplayName = computed(() => {
|
||||
})
|
||||
|
||||
function selectModel(providerId: string, modelId: string) {
|
||||
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock')
|
||||
setModel(modelId)
|
||||
if (setProvider(providerId as Parameters<typeof setProvider>[0])) setModel(modelId)
|
||||
showModelPicker.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -186,8 +186,9 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
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()
|
||||
import { archyBridge } from '@/services/archyBridge'
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
import { useVisualViewport } from '@/composables/useVisualViewport'
|
||||
const codeContext = useCodeContext()
|
||||
@@ -206,11 +207,19 @@ const showSettings = ref(false)
|
||||
// without fixing anything).
|
||||
watch(needsApiKey, (needs) => {
|
||||
if (needs) {
|
||||
showSettings.value = true
|
||||
if (archyBridge.isInArchy()) archyBridge.requestAISetup()
|
||||
else showSettings.value = true
|
||||
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
|
||||
const scrollPositions = new Map<string, number>()
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
<button v-if="isHtml && embedded" class="text-xs px-2.5 py-1 rounded bg-accent/15 text-accent/80" :disabled="preparingWebsite" @click="prepareWebsite">
|
||||
Continue to website setup
|
||||
</button>
|
||||
<button
|
||||
v-if="consoleOutput.length > 0"
|
||||
class="text-xs px-2 py-1 rounded bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10 transition-colors"
|
||||
@@ -19,6 +22,8 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="websiteError" role="alert" class="px-3 py-2 text-xs text-red-300">{{ websiteError }}</p>
|
||||
|
||||
<!-- Code display -->
|
||||
<pre class="px-3 py-2 text-xs text-white/70 overflow-x-auto max-h-48 bg-black/20"><code>{{ code }}</code></pre>
|
||||
|
||||
@@ -53,6 +58,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { archyBridge } from '@/services/archyBridge'
|
||||
|
||||
const props = defineProps<{
|
||||
code: string
|
||||
@@ -64,6 +70,18 @@ interface ConsoleEntry {
|
||||
text: string
|
||||
}
|
||||
|
||||
const embedded = window.parent !== window
|
||||
const preparingWebsite = ref(false)
|
||||
const websiteError = ref('')
|
||||
async function prepareWebsite() {
|
||||
preparingWebsite.value = true; websiteError.value = ''
|
||||
try {
|
||||
const result = await archyBridge.requestAction('prepare-website', { html: props.code })
|
||||
if (!result.success) websiteError.value = result.error || 'Could not open website setup'
|
||||
} catch (e) { websiteError.value = e instanceof Error ? e.message : 'Could not open website setup' }
|
||||
finally { preparingWebsite.value = false }
|
||||
}
|
||||
|
||||
const consoleOutput = ref<ConsoleEntry[]>([])
|
||||
const showIframe = ref(false)
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<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'">
|
||||
API Keys
|
||||
</h3>
|
||||
@@ -93,10 +97,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { archyBridge } from '@/services/archyBridge'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
|
||||
|
||||
const embedded = archyBridge.isInArchy()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
interface ProviderInfo {
|
||||
@@ -156,5 +162,5 @@ async function removeKey(provider: string) {
|
||||
await loadProviders()
|
||||
}
|
||||
|
||||
onMounted(loadProviders)
|
||||
onMounted(() => { if (!embedded) void loadProviders() })
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useCodeContext } from '@/composables/useCodeContext'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
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/)
|
||||
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.
|
||||
${librarySection}`
|
||||
|
||||
const activeProvider = ref<Provider>('claude')
|
||||
const activeProvider = ref<Provider>('routstr')
|
||||
|
||||
const activeModel = ref('claude-haiku-4.5')
|
||||
const activeModel = ref('routstr-unavailable')
|
||||
|
||||
// One-shot signal a send/regenerate/edit failure looked like a missing or
|
||||
// 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).
|
||||
// Credentials require setup; network failures and provider outages require retry.
|
||||
const needsApiKey = ref(false)
|
||||
|
||||
const needsFunding = ref(false)
|
||||
function looksLikeMissingApiKey(err: string): boolean {
|
||||
const lower = err.toLowerCase()
|
||||
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')
|
||||
)
|
||||
return /\b(401|403)\b|api[ _-]?key|unauthorized|authentication_error|credential/i.test(err)
|
||||
}
|
||||
|
||||
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
|
||||
@@ -161,7 +142,7 @@ async function refreshRoutstrModels() {
|
||||
routstrModelsFetched = true
|
||||
try {
|
||||
const res = await apiFetch(ROUTSTR_MODELS_PATH)
|
||||
if (!res.ok) return
|
||||
if (!res.ok) { routstrModelsFetched = false; return }
|
||||
const data = await res.json()
|
||||
if (Array.isArray(data?.data)) {
|
||||
routstrModels.value = data.data
|
||||
@@ -170,13 +151,21 @@ async function refreshRoutstrModels() {
|
||||
id: 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 {
|
||||
routstrModelsFetched = false // allow a retry on the next send/open
|
||||
}
|
||||
}
|
||||
|
||||
const availableProviders = computed(() => {
|
||||
if (archyBridge.isInArchy()) return [
|
||||
{ id: 'routstr' as Provider, name: 'Routstr (sats)', models: routstrModels.value.length ? routstrModels.value : [{ id: 'routstr-unavailable', name: 'Models unavailable — retry' }] },
|
||||
{ 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: 'auto' as Provider, name: 'Node AI', models: [{ id: 'node', name: 'Node configuration' }] },
|
||||
{ id: 'local' as Provider, name: 'Local AI', models: [{ id: 'node', name: 'Node configuration' }] },
|
||||
]
|
||||
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
|
||||
{
|
||||
id: 'routstr',
|
||||
@@ -187,7 +176,7 @@ const availableProviders = computed(() => {
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Claude (Max)',
|
||||
name: 'Claude API',
|
||||
models: [
|
||||
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
|
||||
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
|
||||
@@ -207,24 +196,36 @@ const availableProviders = computed(() => {
|
||||
})
|
||||
providers.push({
|
||||
id: 'mock',
|
||||
name: 'Local (no API)',
|
||||
name: 'Demo echo',
|
||||
models: [{ id: 'echo', name: 'Echo (mirror input)' }],
|
||||
})
|
||||
return providers
|
||||
})
|
||||
|
||||
function setProvider(provider: Provider) {
|
||||
if (archyBridge.isInArchy()) {
|
||||
if (provider === 'routstr' && activeProvider.value === 'routstr') return true
|
||||
archyBridge.requestAISetup(); return false
|
||||
}
|
||||
activeProvider.value = provider
|
||||
const p = availableProviders.value.find((pp) => pp.id === provider)
|
||||
if (p && p.models.length > 0) {
|
||||
activeModel.value = p.models[0].id
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function setModel(model: string) {
|
||||
if (archyBridge.isInArchy() && activeProvider.value !== 'routstr') { archyBridge.requestAISetup(); return }
|
||||
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 {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
@@ -448,6 +449,7 @@ async function streamRoutstr(
|
||||
})
|
||||
|
||||
const bodyText = await res.text().catch(() => '')
|
||||
if (res.status === 402) needsFunding.value = true
|
||||
if (!res.ok) {
|
||||
// The node's refusals carry a plain-language error.message (budget not
|
||||
// set, budget spent, wallet can't fund) — surface it verbatim.
|
||||
@@ -974,5 +976,6 @@ export function useAI() {
|
||||
setProvider,
|
||||
setModel,
|
||||
needsApiKey,
|
||||
needsFunding,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ interface ThemeInfo {
|
||||
type PermissionsCallback = (categories: AIContextCategory[]) => 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
|
||||
const pendingRequests = new Map<string, {
|
||||
resolve: (value: unknown) => void
|
||||
@@ -80,12 +84,19 @@ function postToParent(msg: unknown) {
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
// 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
|
||||
if (!msg || typeof msg.type !== 'string') return
|
||||
|
||||
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': {
|
||||
const pending = pendingRequests.get(msg.id)
|
||||
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 */
|
||||
destroy() {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
pendingRequests.clear()
|
||||
initialized = false
|
||||
currentProvider = null
|
||||
allowedOrigin = null
|
||||
},
|
||||
|
||||
/** Check if running inside Archy iframe */
|
||||
|
||||
@@ -436,13 +436,13 @@
|
||||
{
|
||||
"id": "nginx-proxy-manager",
|
||||
"title": "Nginx Proxy Manager",
|
||||
"version": "2.12.1",
|
||||
"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).",
|
||||
"version": "2.14.0",
|
||||
"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",
|
||||
"author": "Nginx Proxy Manager",
|
||||
"category": "networking",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
@@ -648,9 +648,9 @@
|
||||
{
|
||||
"id": "angor-indexer",
|
||||
"title": "Angor Indexer",
|
||||
"version": "1.0.1",
|
||||
"description": "Headless Bitcoin indexer endpoint for Angor. 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.1",
|
||||
"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",
|
||||
@@ -673,6 +673,73 @@
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"repoUrl": "https://github.com/hoytech/strfry"
|
||||
},
|
||||
{
|
||||
"id": "justworks",
|
||||
"title": "Just Works",
|
||||
"version": "0.1.0",
|
||||
"description": "Turn your existing business links into a website with Just Works. Open your website editor and business tools from one lightweight launcher. Uses the hosted Just Works services; an internet connection is required.",
|
||||
"icon": "/assets/img/app-icons/justworks.svg",
|
||||
"author": "Just Works contributors",
|
||||
"category": "business",
|
||||
"tier": "optional",
|
||||
"repoUrl": "https://github.com/bencoin21/justworks.cash",
|
||||
"dockerImage": "localhost/archipelago-justworks:0.1.0"
|
||||
},
|
||||
{
|
||||
"id": "datum",
|
||||
"author": "OCEAN contributors",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
],
|
||||
"title": "DATUM",
|
||||
"version": "0.4.1-beta.1",
|
||||
"description": "Build Bitcoin mining templates on your own node and connect your miners to OCEAN through DATUM.",
|
||||
"dockerImage": "localhost/archipelago-datum:0.4.1-beta.1",
|
||||
"category": "bitcoin",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/datum.svg",
|
||||
"repoUrl": "https://github.com/OCEAN-xyz/datum_gateway"
|
||||
},
|
||||
{
|
||||
"id": "gashboard",
|
||||
"author": "Gashboard contributors",
|
||||
"requires": [
|
||||
"datum"
|
||||
],
|
||||
"title": "Gashboard",
|
||||
"version": "0.2.1",
|
||||
"description": "A playful mining dashboard for your DATUM fleet, with live hashrates, share history, lottery odds, chat and a Nostr viewer access list.",
|
||||
"dockerImage": "localhost/archipelago-gashboard:0.2.1",
|
||||
"category": "bitcoin",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/gashboard.svg",
|
||||
"repoUrl": "https://gitworkshop.dev/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy"
|
||||
},
|
||||
{
|
||||
"id": "blossom",
|
||||
"author": "hzrd149 / Archipelago",
|
||||
"requires": [],
|
||||
"tier": "optional",
|
||||
"title": "Blossom",
|
||||
"version": "6.4.1-archy.2",
|
||||
"description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.",
|
||||
"dockerImage": "localhost/archipelago-blossom:6.4.1-archy.2",
|
||||
"category": "data",
|
||||
"repoUrl": "https://github.com/hzrd149/blossom-server",
|
||||
"icon": "/assets/img/app-icons/blossom.svg"
|
||||
},
|
||||
{
|
||||
"id": "public-web-router",
|
||||
"author": "Archipelago",
|
||||
"requires": [],
|
||||
"tier": "optional",
|
||||
"title": "Public Web Router",
|
||||
"version": "0.1.0",
|
||||
"description": "Connect explicitly published websites to your own public gateway. HTTPS keys stay on this node. Configure routes through Setup.",
|
||||
"dockerImage": "localhost/archipelago-public-web-router:0.1.0",
|
||||
"category": "networking",
|
||||
"icon": "/assets/img/app-icons/nginx.svg"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,3 +91,5 @@ Adding a new app requires updates in multiple places:
|
||||
## Port Assignments
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Angor Indexer
|
||||
|
||||
Headless mainnet API endpoint for Angor. The service reuses this node's Mempool
|
||||
backend and Electrum index instead of creating a second blockchain database.
|
||||
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.
|
||||
@@ -32,12 +33,45 @@ Install **Angor Relay** separately to host project metadata locally, then add
|
||||
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.1 apps/angor-indexer/container
|
||||
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.
|
||||
@@ -55,3 +89,21 @@ 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.
|
||||
|
||||
@@ -15,6 +15,14 @@ http {
|
||||
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;
|
||||
@@ -23,7 +31,8 @@ http {
|
||||
proxy_send_timeout 30s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Connection "";
|
||||
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;
|
||||
@@ -35,10 +44,6 @@ http {
|
||||
# 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;
|
||||
location = / {
|
||||
default_type application/json;
|
||||
return 200 '{"service":"Angor Indexer","network":"mainnet","api":"/api/v1","health":"/health"}\n';
|
||||
}
|
||||
# Readiness checks the indexing backend, not this gateway's process.
|
||||
location = /health {
|
||||
limit_except GET { deny all; }
|
||||
@@ -54,10 +59,23 @@ http {
|
||||
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;
|
||||
}
|
||||
location / { return 404; }
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
app:
|
||||
id: angor-indexer
|
||||
name: Angor Indexer
|
||||
version: 1.0.1
|
||||
description: Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool
|
||||
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.1
|
||||
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
|
||||
@@ -40,8 +44,8 @@ app:
|
||||
interfaces:
|
||||
main:
|
||||
name: Angor Indexer API
|
||||
description: Use this origin as Angor’s custom mainnet indexer URL. HTTPS is
|
||||
required for browser clients.
|
||||
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
|
||||
@@ -63,6 +67,7 @@ app:
|
||||
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
|
||||
|
||||
@@ -8,6 +8,18 @@ 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
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Blossom on Archipelago
|
||||
|
||||
Candidate package, not a published catalogue release. Follow
|
||||
[`docs/app-developer-guide.md`](../../docs/app-developer-guide.md) and
|
||||
[`docs/candidate-catalog-qualification.md`](../../docs/candidate-catalog-qualification.md)
|
||||
for lifecycle and catalogue acceptance.
|
||||
|
||||
## Package contract
|
||||
|
||||
- MIT upstream `hzrd149/blossom-server` 6.4.1, source commit
|
||||
`a492dc61c4a581bbd0992546b2aec6f9aa543f75`. The Dockerfile verifies the source
|
||||
archive SHA-256 and uses upstream's frozen dependency lock for the server.
|
||||
- Manifest-owned local build; the runtime payload must include `docker/blossom`.
|
||||
No unpublished registry image is advertised. Initial installation needs access
|
||||
to the open-source build dependencies; normal startup uses cached dependencies.
|
||||
- Rootless container, read-only root, no capabilities, no new privileges,
|
||||
explicit `slirp4netns`. Host port 8191 binds IPv4 loopback behind AppGate.
|
||||
Keep that private backend binding: any FIPS/IPv6 ingress belongs at the gate.
|
||||
- Persistent data and SQLite under `/var/lib/archipelago/blossom/data`.
|
||||
Preserve this directory on uninstall. No automatic expiry/pruning, automatic
|
||||
mirroring, media conversion, or upstream administration dashboard.
|
||||
- Uploads require BUD-11 signatures from the profile identities supplied by
|
||||
`{{NODE_IDENTITY_PUBKEYS}}`. No profile means startup fails closed. Changes to
|
||||
that allowlist take effect on restart, including revocation of removed profiles.
|
||||
The appliance identity is excluded. Listing requires the owner's signature.
|
||||
- The custom local UI loads the canonical, host-managed `nostr-provider.js`
|
||||
through the documented lifecycle hook. A missing provider fails verification.
|
||||
The UI uses the platform identity chooser and ordinary NIP-07 signing; there
|
||||
is no generated browser key, nsec input, or second consent modal.
|
||||
- Upload authorization is scoped to the file hash, actual server hostname and
|
||||
five-minute expiry. Local upload does not send a public Nostr announcement.
|
||||
- Uploaded files are returned as sandboxed attachments. Untrusted HTML/SVG must
|
||||
not acquire this app's origin or signer access. Published website rendering
|
||||
needs the separate website origin, not a relaxation of this policy.
|
||||
|
||||
AppGate protects reads as well as the UI. Blossom itself is content-addressed,
|
||||
not an encrypted per-user vault: other authorized node users who know a hash can
|
||||
retrieve its bytes. Do not open the whole app gate to publish one website. Public
|
||||
asset serving must authorize exact selected hashes; external replication requires
|
||||
its own explicit content/destination review. An inaccessible local URL is not a
|
||||
working public Blossom endpoint.
|
||||
|
||||
## Qualification evidence — 2026-10-08
|
||||
|
||||
- Manifest preflight: 16 passed, no warnings. Generated catalogue drift: zero.
|
||||
- Candidate built and started on Framework with read-only root and the declared
|
||||
resource/security constraints. All protocol tests use synthetic identities and
|
||||
files; no public relay or external Blossom server is contacted.
|
||||
- `tests/apps/blossom/protocol.ts` passed against the candidate: authenticated
|
||||
upload/readback, exact hash/size/bytes, wrong identity/server/expired/anonymous
|
||||
upload rejection, owner-only listing, disabled mirror, canonical provider and
|
||||
health endpoint. HTML response has sandbox CSP and attachment headers.
|
||||
- Fixture survived container recreation with the same data directory and restart
|
||||
with explicit slirp4netns. An earlier test using Podman's default pasta hit a
|
||||
transient port teardown conflict; that is not the package's configured network.
|
||||
- Canonical Rust parser: all shipped manifests parse in the isolated test runner.
|
||||
- Setup/source tests: 13 passed, dashboard typecheck passed including the final
|
||||
receipt-review presentation changes.
|
||||
- Packaged UI passed a real Chromium test at mobile width: explicit identity
|
||||
chooser, consent before upload, signer refusal blocks upload, hash/host-scoped
|
||||
upload, consent reset and no external requests. Signer and upload transport
|
||||
were mocked for this UI test; live protocol checks above are separate.
|
||||
- Framework's normal installer succeeded after the operator temporarily disabled
|
||||
dashboard 2FA. Candidate manifest and build context are staged in the runtime
|
||||
payload. The app is healthy, with its canonical bridge installed by the hook,
|
||||
read-only root, slirp4netns and a loopback backend behind AppGate. Anonymous
|
||||
HTTPS access on port 8191 returns the gate's 401 sign-in page.
|
||||
- Real HTTPS tab signer acceptance passed: profile chooser, refusal prevents any
|
||||
upload, and an approved BUD-11 authorization stores a synthetic local file.
|
||||
No real identity key was exported or public Nostr event sent. Existing native
|
||||
Bitcoin/LND processes retained their original start times during installation.
|
||||
- No signed catalogue, source proposal, public Nostr event, OTA or ISO published.
|
||||
|
||||
- Real HTTP tab signing also passed. Normal app stop/start, restart with a new
|
||||
container, uninstall with `preserve_data:true`, reinstall, and management restart
|
||||
all preserved the uploaded synthetic file, verified by hash. Native Bitcoin/LND
|
||||
processes retained their original start times.
|
||||
- Local website archive integration is implemented in source: an explicit action
|
||||
signs a hash/server-scoped upload, stores the saved draft through the local
|
||||
manifest-owned Blossom backend, verifies exact readback and records a receipt.
|
||||
It does not announce or replicate anything. Its backend RPC is not yet deployed.
|
||||
|
||||
Still required before release: cross-profile identity switch (only one profile
|
||||
was available), HTTP/HTTPS iframe and physical companion validation, arranged
|
||||
reboot, integrated website archive acceptance, then reviewed source/mirror parity
|
||||
and signed catalogue gates. Selective public asset routes remain separate work;
|
||||
the authenticated app address must never be advertised as a public Blossom URL.
|
||||
Restore dashboard 2FA with the operator after live testing.
|
||||
|
||||
### Companion follow-up — 2026-10-08
|
||||
|
||||
Blossom now requests the canonical identity chooser once when opened, identifies
|
||||
itself explicitly to the tab signer, and disables the provider's unrelated
|
||||
NIP-98 web-app login. Cancelled selection leaves a retry button; uploads still
|
||||
require file review and signer approval. A host signer bug sent a Vue reactive
|
||||
Proxy through postMessage after selection, closing the picker but stranding the
|
||||
app behind an empty signer. The host now copies only public identity fields.
|
||||
The reactive-object regression test and a real direct-app mobile-width browser
|
||||
check pass: automatic chooser, closed signer, visible app, denied upload and
|
||||
approved local upload. Physical companion confirmation remains pending.
|
||||
The corrected image is a private rebuild of the existing candidate tag; assign
|
||||
an updated package/image version before reviewed catalogue publication.
|
||||
@@ -0,0 +1,87 @@
|
||||
app:
|
||||
id: blossom
|
||||
name: Blossom
|
||||
version: 6.4.1-archy.2
|
||||
upstream:
|
||||
kind: github
|
||||
repo: hzrd149/blossom-server
|
||||
description: Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.
|
||||
category: data
|
||||
container:
|
||||
network: slirp4netns
|
||||
build:
|
||||
context: /opt/archipelago/docker/blossom
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-blossom:6.4.1-archy.2
|
||||
derived_env:
|
||||
- key: ARCHY_BLOSSOM_PUBKEYS
|
||||
template: '{{NODE_IDENTITY_PUBKEYS}}'
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512m
|
||||
disk_limit: 5Gi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
seccomp_profile: default
|
||||
ports:
|
||||
- host: 8191
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/blossom/data
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/blossom/bridge
|
||||
target: /bridge
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/blossom/config.json
|
||||
target: /config/config.json
|
||||
options: [ro]
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
options: [rw, nosuid, nodev, size=64m]
|
||||
files:
|
||||
- path: /var/lib/archipelago/blossom/config.json
|
||||
overwrite: false
|
||||
content: '{}'
|
||||
hooks:
|
||||
post_install:
|
||||
- copy_from_host:
|
||||
src: web-ui/nostr-provider.js
|
||||
dest: /bridge/nostr-provider.js
|
||||
- exec: [sh, -c, 'test -s /bridge/nostr-provider.js']
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:3000
|
||||
path: /healthz
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
interfaces:
|
||||
main:
|
||||
name: Local files
|
||||
type: ui
|
||||
port: 8191
|
||||
protocol: http
|
||||
path: /
|
||||
metadata:
|
||||
author: hzrd149 / Archipelago
|
||||
tier: optional
|
||||
icon: /assets/img/app-icons/blossom.svg
|
||||
license: MIT
|
||||
repo: https://github.com/hzrd149/blossom-server
|
||||
tags: [nostr, blossom, storage, websites]
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
requires_host_frame: false
|
||||
@@ -0,0 +1,86 @@
|
||||
# DATUM on Archipelago
|
||||
|
||||
Packages OCEAN DATUM v0.4.1beta, pinned to upstream commit
|
||||
`5b061233a3d3323771b2be98e17f543e59346619`. The local build context must ship at
|
||||
`/opt/archipelago/docker/datum`; no published registry image is assumed.
|
||||
|
||||
## First launch
|
||||
|
||||
Install a Bitcoin node and allow it to synchronize, then install DATUM. Open its
|
||||
app tile and set your own Bitcoin payout address in DATUM's configuration page.
|
||||
The initial address is deliberately empty: upstream keeps the UI available while
|
||||
waiting for a valid address instead of mining to somebody else's address.
|
||||
The admin username is `admin`. The generated password is stored on the node at
|
||||
`/var/lib/archipelago/secrets/datum-admin-password`; retrieve it locally as the
|
||||
node administrator. Do not put it in miner passwords or share it with miners.
|
||||
|
||||
Point miners at `stratum+tcp://<node-LAN-hostname>:23334`. Use a unique worker
|
||||
name for every miner, following upstream's payout/worker naming rules:
|
||||
https://github.com/OCEAN-xyz/datum_gateway/blob/v0.4.1beta/doc/usernames.md
|
||||
The default is pooled mining only; loss of the pool connection stops mining
|
||||
rather than silently switching to solo mining. DATUM's web UI reports template,
|
||||
Bitcoin and pool readiness; an HTTP health check only proves the UI is alive.
|
||||
|
||||
## Stable connections
|
||||
|
||||
Gashboard connects inside `archy-net` to `http://datum:7152`, using Podman's DNS
|
||||
alias. Never copy a container IP into either app's configuration. Bitcoin's DNS
|
||||
name is resolved from `BITCOIN_HOST` on each start, and the shared RPC secret and
|
||||
DATUM admin secret are refreshed without discarding the operator's settings.
|
||||
|
||||
External miners connect to the **node**, not its container. Use a DHCP reservation
|
||||
on your router and a LAN DNS name if the miner supports DNS. Some miners do not
|
||||
support mDNS (`.local`); use the reserved LAN IP for those. Container DNS fixes
|
||||
container recreation, while the reservation prevents the node's DHCP address
|
||||
from moving. Neither setting requires host networking.
|
||||
|
||||
Only Stratum is published directly. The admin UI is loopback-bound behind the
|
||||
Archipelago app gate and retains DATUM's admin authentication. The backend uses
|
||||
upstream's block notification polling fallback, so installing DATUM does not
|
||||
rewrite or restart Bitcoin to add a `blocknotify` command.
|
||||
|
||||
## Data and validation
|
||||
|
||||
Settings live in `/var/lib/archipelago/datum/config.json` with mode 0600. Preserve
|
||||
that directory and the platform secrets when uninstalling/reinstalling.
|
||||
|
||||
Before catalog publication, validate install, setup, Bitcoin IBD and recovery,
|
||||
accepted shares from a real miner, stop/start, container recreation, preserved-data
|
||||
reinstall, backend restart and a controlled node reboot. Verify Gashboard recovers
|
||||
after DATUM receives a different container address. These live-node checks are
|
||||
separate from the local manifest/build checks and require a dedicated test node.
|
||||
|
||||
## Local validation (2026-10-06)
|
||||
|
||||
The pinned image builds on Linux/amd64. Its UI returns HTTP 200 while waiting
|
||||
for setup, `/clients` rejects unauthenticated requests, and its config is 0600.
|
||||
The container runs with read-only root, cap-drop ALL and no-new-privileges.
|
||||
Three config regression tests cover empty first-run payout, preserved payout
|
||||
policy (including explicit false settings), secret/DNS refresh and invalid input.
|
||||
Gashboard successfully polls this image using digest authentication and reconnects
|
||||
when its container IP changes. Manifest preflight and generated catalog drift
|
||||
checks pass. The catalog entries in this branch are review candidates; no signed
|
||||
catalog or image has been published. Real mining shares and full lifecycle acceptance remain required before release.
|
||||
|
||||
## Operator-authorized node deployment (2026-10-06)
|
||||
|
||||
Installed as rootless Podman apps on archi-dev-box and yaya-server, with the
|
||||
manifest and build context staged in the runtime payload. Both nodes report
|
||||
DATUM healthy. Chromium verified the normalized My Apps icon, title, Launch
|
||||
button, and embedded native UI with its Config navigation on both nodes.
|
||||
|
||||
On yaya, a normal package restart recreated DATUM at 10.89.0.11 instead of
|
||||
10.89.0.9. Its configuration checksum was unchanged, and the still-running
|
||||
Gashboard resolved `datum` to the new address and resumed successful authenticated
|
||||
polling. Existing app container IDs remained unchanged on both hosts.
|
||||
|
||||
The payout address remains unset. HTTP health proves that setup is available,
|
||||
not that Bitcoin/pool readiness or accepted mining shares have been established.
|
||||
No node reboot, IBD experiment, preserved-data reinstall, signed catalog release,
|
||||
or registry publication was performed in this deployment.
|
||||
|
||||
The deployed platform drops manifest UI/icon metadata from its initial Installing
|
||||
placeholder, briefly showing a disk-only app under Services. Once scanned, both
|
||||
apps appear in My Apps with their declared icons and launch interfaces. A separate
|
||||
platform fix is being prepared; do not claim the installation-placeholder issue
|
||||
is fixed merely because the completed installation is displayed correctly.
|
||||
@@ -0,0 +1,84 @@
|
||||
app:
|
||||
id: datum
|
||||
name: DATUM
|
||||
version: 0.4.1-beta.1
|
||||
description: Build Bitcoin mining templates on your own node and connect your miners to OCEAN through DATUM.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: OCEAN-xyz/datum_gateway
|
||||
container_name: datum
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/datum
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-datum:0.4.1-beta.1
|
||||
network: archy-net
|
||||
network_aliases: [datum]
|
||||
data_uid: "1000:1000"
|
||||
derived_env:
|
||||
- key: BITCOIN_RPC_HOST
|
||||
template: "{{BITCOIN_HOST}}"
|
||||
generated_secrets:
|
||||
- name: datum-admin-password
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: DATUM_ADMIN_PASSWORD
|
||||
secret_file: datum-admin-password
|
||||
dependencies:
|
||||
- app_id: bitcoin-knots
|
||||
- storage: 1Gi
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 512m
|
||||
disk_limit: 1Gi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
ports:
|
||||
- host: 7152
|
||||
container: 7152
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
- host: 23334
|
||||
container: 23334
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: Stratum mining clients require a raw TCP connection and cannot complete a browser login. Payout worker names are handled by DATUM; the administration UI uses a separate gated port.
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/datum
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
tmpfs_options: rw,noexec,nosuid,size=16m
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:7152
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
interfaces:
|
||||
main:
|
||||
name: DATUM Gateway
|
||||
type: ui
|
||||
port: 7152
|
||||
protocol: http
|
||||
path: /
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
pruning_support: true
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/datum.svg
|
||||
category: bitcoin
|
||||
tier: optional
|
||||
repo: https://github.com/OCEAN-xyz/datum_gateway
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
@@ -0,0 +1,129 @@
|
||||
# Gashboard on Archipelago
|
||||
|
||||
Install DATUM and configure its payout address, then install Gashboard. The app
|
||||
connects to `http://datum:7152` on `archy-net`, so recreating DATUM does not require
|
||||
copying a new container IP. The shared DATUM admin password is injected as a
|
||||
platform secret and never sent to the browser. This PR depends on the DATUM app
|
||||
package being merged and its build context being shipped.
|
||||
|
||||
## Sign in and invite miners
|
||||
|
||||
Use **Sign in with Nostr** inside Archipelago to choose a node identity through
|
||||
the native signer. A standalone visitor can use a browser extension or remote
|
||||
Nostr signer. No private key is entered into Gashboard.
|
||||
|
||||
All user identities offered by Archipelago's signer are dashboard owners through
|
||||
`NODE_IDENTITY_PUBKEYS`; the appliance identity is excluded. Owners can open
|
||||
**Access**, paste another miner's `npub`, and add them as a viewer. Share the
|
||||
Gashboard URL on port 1337 over your intended node access route. Viewers can read
|
||||
the entire fleet and join dashboard chat; they cannot edit membership or configure
|
||||
DATUM. Access is independent of the miner's Stratum worker name. Signing with an
|
||||
unlisted identity is rejected even if that person mines through DATUM.
|
||||
|
||||
Owners can remove a viewer in Access. Every authenticated request rechecks the
|
||||
list, so an already-issued JWT stops working immediately. Removing a viewer does
|
||||
not erase information or chat keys that their browser previously received.
|
||||
Membership lives in `/var/lib/archipelago/gashboard/access.json`; preserve this
|
||||
directory and the platform JWT secret across reinstall. Node owners are managed
|
||||
in Archipelago identities, not in Gashboard. Restart Gashboard after changing
|
||||
node identities to refresh owner access.
|
||||
|
||||
The app gate uses `auth: open` because invited miners have Gashboard membership,
|
||||
not node administrator accounts. Gashboard still authenticates every data API.
|
||||
The gate supplies HTTPS and iframe header handling. A node administrator can
|
||||
enable the gate's extra login if only node users should reach the app.
|
||||
|
||||
## Source and native signer
|
||||
|
||||
`docker/gashboard` vendors the user-supplied Gashboard source at commit
|
||||
`68b606b` from `/home/yaya/Projects/gashboard`, with Archipelago integration and
|
||||
membership changes reviewed here. Its configured remote,
|
||||
`https://git.tx1138.com/lfg2025/gashboard.git`, was unavailable over TLS during
|
||||
preparation; upstream freshness has not been verified. Vendoring makes the build
|
||||
independent of that server. The original application declares the MIT license.
|
||||
|
||||
The image bakes the canonical `neode-ui/public/nostr-provider.js`; the install
|
||||
hook refreshes its persisted copy from the node. Refresh the baked copy when
|
||||
updating the package. The server serves the provider uncached with
|
||||
`data-app-id="gashboard"` and `data-no-nip98`, preserving Gashboard's own NIP-98
|
||||
login. The service worker never caches the signer. Existing NIP-07 browser
|
||||
providers take precedence over the node provider. The CSP permits the native
|
||||
signer broker frame in standalone/companion launches.
|
||||
|
||||
`/healthz` checks that the dashboard API is serving. DATUM connection failures are
|
||||
reported in the dashboard snapshot rather than disguised as a healthy mining
|
||||
fleet. Contribution history persists under `/data`; live miner connections and
|
||||
current hash rate recover through repeated DNS-based polling. Miner display names
|
||||
come from their worker names, and history uses the full Stratum username so
|
||||
multiple miners of the same model are not combined under a preset nickname.
|
||||
Use a distinct worker name for each miner. Old Umbrel history should not be
|
||||
copied blindly: its ledger used preset nicknames as identities.
|
||||
|
||||
Before release, validate HTTP/HTTPS iframe launch, companion launch, native
|
||||
identity consent, invited-user login, revocation, DATUM address change/recovery,
|
||||
and install/start/stop/reinstall/reboot on a dedicated Archipelago test node.
|
||||
|
||||
## Local validation (2026-10-06)
|
||||
|
||||
API access-control and worker-identity tests, TypeScript checks, production builds,
|
||||
manifest validation and generated catalog drift checks pass. Both container images
|
||||
build and run with read-only roots, cap-drop ALL and no-new-privileges on Docker.
|
||||
Gashboard's digest-authenticated polling recovered after DATUM moved from
|
||||
172.22.0.2 to 172.22.0.4, without restarting or reconfiguring Gashboard, and after
|
||||
another DATUM restart with preserved settings.
|
||||
|
||||
The access/login flow passed Chromium 153, Firefox 155 and WebKit 26.6, each at
|
||||
1440x900 and 390x844: native-provider NIP-98 through a simulated host frame,
|
||||
invalid-key feedback, invitation, reload persistence, removal, and layout fit.
|
||||
API tests also verify owner-only edits, rejected outsider login, persisted
|
||||
membership, owner protection, corruption refusal, and revoked JWT/SSE access.
|
||||
Browser scenarios and screenshots remain outside the repository in the shared
|
||||
browser-check workspace. These browser tests simulate the app gate and signer
|
||||
host; they do not establish real-node consent, HTTPS or Android acceptance.
|
||||
|
||||
The generated storefront entries are review candidates. No signed catalog,
|
||||
registry image or release was published. Keep actual-node acceptance
|
||||
separate from these local results.
|
||||
|
||||
## Operator-authorized node deployment (2026-10-06)
|
||||
|
||||
Installed alongside DATUM on archi-dev-box and yaya-server using rootless Podman,
|
||||
read-only roots and the node-generated secrets. Both apps report healthy. Their
|
||||
My Apps tiles show normalized icons, names and Launch controls. Chromium verified
|
||||
Gashboard's embedded launch, native identity selection/signing, and owner Access
|
||||
page on both nodes. Standalone native login and fresh DATUM polling passed too.
|
||||
|
||||
On yaya, Chromium 153, Firefox 155 and WebKit 26.6 passed owner login, Access-page
|
||||
layout and reload persistence at 1440x900 and 390x844. These are Linux browser and
|
||||
viewport checks, not Android companion or physical iPhone acceptance. Anonymous
|
||||
requests to mining data and membership endpoints returned 401. No viewers were
|
||||
added to the live allowlist during these read-only UI checks.
|
||||
|
||||
DATUM's normal package restart on yaya changed its address from 10.89.0.9 to
|
||||
10.89.0.11; Gashboard was not restarted or reconfigured and its authenticated stats
|
||||
API returned a successful poll less than five seconds old afterwards. Gashboard
|
||||
also completed its own normal package restart. The previous Docker tests cover
|
||||
invitation, revocation and membership persistence; full live-node membership,
|
||||
HTTPS, companion, preserved-data reinstall and reboot acceptance remain separate.
|
||||
|
||||
Payouts are not configured and no real miner shares were submitted in this check.
|
||||
These are node test deployments of review candidates, not catalog publication.
|
||||
|
||||
### Private chat persistence and invitations
|
||||
|
||||
Encrypted messages, reactions and per-recipient room-key wraps are saved atomically
|
||||
in `/data/chat.json` (mode 0600), alongside the viewer list. The server never saves
|
||||
the plaintext room key or decrypted messages. Back up the whole app data directory;
|
||||
keep chat history and key wraps together. Invalid saved chat data stops startup
|
||||
instead of silently discarding history.
|
||||
|
||||
An existing member with chat open shares the existing room key with newly invited
|
||||
viewers. If no existing member is online, the new viewer sees a pending-key message;
|
||||
an existing member must open chat, then the viewer can reopen the panel. A new
|
||||
viewer or simultaneous first visitor cannot replace the established key. Existing
|
||||
history becomes readable to invited viewers. Revoking membership blocks API access
|
||||
but cannot erase a key or history already received by that viewer.
|
||||
|
||||
When upgrading from 0.2.0, export the authenticated `/api/chat` snapshot to
|
||||
`/data/chat.json` before stopping the old container: that version holds chat only
|
||||
in memory. Preserve the snapshot and data-directory backup through the upgrade.
|
||||
@@ -0,0 +1,90 @@
|
||||
app:
|
||||
id: gashboard
|
||||
name: Gashboard
|
||||
version: 0.2.1
|
||||
description: A playful mining dashboard for your DATUM fleet, with live hashrates, share history, lottery odds, chat and a Nostr viewer access list.
|
||||
upstream:
|
||||
kind: internal
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/gashboard
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-gashboard:0.2.1
|
||||
network: archy-net
|
||||
data_uid: "1000:1000"
|
||||
derived_env:
|
||||
- key: NOSTR_OWNER_PUBKEYS
|
||||
template: "{{NODE_IDENTITY_PUBKEYS}}"
|
||||
generated_secrets:
|
||||
- name: gashboard-jwt-secret
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: JWT_SECRET
|
||||
secret_file: gashboard-jwt-secret
|
||||
- key: DATUM_ADMIN_PASSWORD
|
||||
secret_file: datum-admin-password
|
||||
install_prerequisites: [datum]
|
||||
dependencies:
|
||||
- app_id: datum
|
||||
- storage: 1Gi
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512m
|
||||
disk_limit: 1Gi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
ports:
|
||||
- host: 1337
|
||||
container: 1337
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: open
|
||||
auth_rationale: Gashboard verifies NIP-98 signatures and an owner-managed Nostr access list before issuing sessions. Every data route rechecks membership; invited miners must not need a node administrator login.
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/gashboard
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
tmpfs_options: rw,noexec,nosuid,size=16m
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=1337
|
||||
- DATUM_URL=http://datum:7152
|
||||
- DATUM_ADMIN_USER=admin
|
||||
- CONTRIBUTION_LEDGER_PATH=/data/contribution-ledger.json
|
||||
- ACCESS_LIST_PATH=/data/access.json
|
||||
- CHAT_STORE_PATH=/data/chat.json
|
||||
- ARCHIPELAGO_PROVIDER_PATH=/data/nostr-provider.js
|
||||
- MEMPOOL_API_URL=https://mempool.space/api
|
||||
hooks:
|
||||
post_install:
|
||||
- copy_from_host:
|
||||
src: web-ui/nostr-provider.js
|
||||
dest: /data/nostr-provider.js
|
||||
- exec: ["test", "-s", "/data/nostr-provider.js"]
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:1337
|
||||
path: /healthz
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
interfaces:
|
||||
main:
|
||||
name: Mining dashboard
|
||||
type: ui
|
||||
port: 1337
|
||||
protocol: http
|
||||
path: /
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/gashboard.svg
|
||||
category: bitcoin
|
||||
tier: optional
|
||||
repo: https://gitworkshop.dev/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
@@ -67,6 +67,7 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
icon: /assets/img/app-icons/homeassistant.png
|
||||
category: home
|
||||
author: Home Assistant
|
||||
|
||||
@@ -84,5 +84,6 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
|
||||
@@ -15,6 +15,8 @@ app:
|
||||
container_name: indeedhub-api
|
||||
|
||||
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
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
|
||||
@@ -69,10 +69,12 @@ app:
|
||||
- copy_from_host:
|
||||
src: "web-ui/nostr-provider.js"
|
||||
dest: "/usr/share/nginx/html/nostr-provider.js"
|
||||
- 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", "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: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/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"]
|
||||
# Compose the outer app-proxy prefix for NIP-98 signed URL verification.
|
||||
- exec: ["sed", "-i", "s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|", "/etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["nginx", "-s", "reload"]
|
||||
|
||||
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
|
||||
|
||||
@@ -63,6 +63,7 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
icon: /assets/img/app-icons/jellyfin.webp
|
||||
category: data
|
||||
author: Jellyfin
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
app:
|
||||
id: justworks
|
||||
name: Just Works
|
||||
version: 0.1.0
|
||||
description: >-
|
||||
Turn your existing business links into a website with Just Works.
|
||||
Open your website editor and business tools from one lightweight launcher.
|
||||
Uses the hosted Just Works services; an internet connection is required.
|
||||
category: business
|
||||
upstream:
|
||||
kind: manual
|
||||
url: https://github.com/bencoin21/justworks-business
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/justworks
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-justworks:0.1.0
|
||||
network: archy-net
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 256m
|
||||
disk_limit: 128Mi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
ports:
|
||||
- host: 8340
|
||||
container: 8340
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
session_passthrough: true
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/justworks
|
||||
target: /data
|
||||
environment: []
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8340
|
||||
path: /healthz
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
interfaces:
|
||||
main:
|
||||
name: Just Works
|
||||
description: Website and business tools
|
||||
type: ui
|
||||
port: 8340
|
||||
protocol: http
|
||||
path: /
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/justworks.svg
|
||||
author: Just Works contributors
|
||||
repo: https://github.com/bencoin21/justworks.cash
|
||||
tier: optional
|
||||
launch:
|
||||
requires_host_frame: true
|
||||
open_in_new_tab: false
|
||||
@@ -59,6 +59,7 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
icon: /assets/img/app-icons/nextcloud.webp
|
||||
category: data
|
||||
author: Nextcloud
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
app:
|
||||
id: nginx-proxy-manager
|
||||
name: Nginx Proxy Manager
|
||||
version: 2.12.1
|
||||
version: 2.14.0
|
||||
upstream:
|
||||
kind: github
|
||||
repo: NginxProxyManager/nginx-proxy-manager
|
||||
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).
|
||||
The node's public web server forwards configured domains through this
|
||||
service, preserving its access lists, certificates and custom routes.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
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
|
||||
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:
|
||||
- storage: 1Gi
|
||||
@@ -49,6 +52,17 @@ app:
|
||||
Nginx Proxy Manager enforces its own admin account on every page;
|
||||
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:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nginx-proxy-manager
|
||||
|
||||
@@ -60,6 +60,7 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
icon: /assets/img/app-icons/photoprism.svg
|
||||
category: data
|
||||
author: PhotoPrism
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Public Web Router
|
||||
|
||||
Optional, manifest-first rootless app for node-terminated HTTPS through an
|
||||
operator-owned frp gateway. Uses pinned frpc0.71.0 and Caddy2.11.7 binaries and a
|
||||
pinned multi-architecture Python base. No host network, host port, capabilities,
|
||||
privileged socket, or node signing keys are needed. FIPS connects the isolated
|
||||
container to explicitly published website listeners.
|
||||
|
||||
Setup stores the private enrollment and derived routes in
|
||||
`/var/lib/archipelago/public-web-router/config/router.json` (0600). The app mounts
|
||||
that directory read-only, watches for atomic replacement, validates input, and
|
||||
supervises only its own Caddy and frpc processes. Removing or invalidating config
|
||||
stops both. The gateway CA is pinned; HTTPS SNI passes through to Caddy. Caddy
|
||||
keeps certificate keys under the persistent `/data` bind mount. Uninstall and
|
||||
Disconnect must preserve that data unless the user explicitly requests removal.
|
||||
|
||||
The automatic adapter accepts website IDs or guest-enabled app IDs and resolves
|
||||
saved domains, FIPS addresses and listener ports on the backend. Arbitrary target
|
||||
URLs/ports and management endpoints are not accepted. App routes require the
|
||||
installed catalogue policy to enable guest sharing and retain authentication.
|
||||
Each request carries the expected project/app identity. The app gate rechecks
|
||||
its live policy before login actions or static exceptions; a stale route cannot
|
||||
follow a reassigned port or a disabled gate. Existing manual proxies still work.
|
||||
|
||||
No Nostr signer integration is requested: routing neither signs nor broadcasts
|
||||
Nostr events. Blossom/nsite publication continues to use its explicit profile
|
||||
signer and exact-byte review. Enrollment files contain private credentials and
|
||||
must never enter that publishing flow.
|
||||
|
||||
Public mode requests ACME using TLS-ALPN-01. A dedicated public443 path must reach
|
||||
the node through the gateway; competing gateways/proxies must not claim it.
|
||||
Explicit test mode uses a private Caddy CA and is not browser-trusted public TLS.
|
||||
The process-health probe reports supervision, not external reachability or
|
||||
certificate issuance. Setup's independent HTTPS exact-content check remains
|
||||
required before claiming public reachability.
|
||||
|
||||
Framework qualification passed the signed private catalogue, normal manifest
|
||||
installer, owner-RPC enrollment, exact website bytes through isolated Yaya TLS,
|
||||
and app guest-cookie issue/revocation. Public ACME on port 443 remains untested;
|
||||
the isolated test uses a private CA. General publication still requires the
|
||||
repository release gates.
|
||||
|
||||
Distribution must include both `apps/public-web-router` and
|
||||
`docker/public-web-router` in the runtime payload. Build-source manifests defer
|
||||
to the shipped disk manifest; the catalogue alone cannot install the build
|
||||
context. On nodes with `web-ui/archipelago-runtime`, update that payload too:
|
||||
startup restores it into `/opt/archipelago`. Do not patch only the live copy.
|
||||
|
||||
The manifest requests CPU/memory limits. Framework's rootless runtime currently
|
||||
reports no enforced memory cgroup limit; do not present the requested 256 MiB as
|
||||
an enforced limit on that host. Read-only root, dropped capabilities, slirp and
|
||||
read-only configuration mounts were verified on the normally installed app.
|
||||
@@ -0,0 +1,49 @@
|
||||
app:
|
||||
id: public-web-router
|
||||
name: Public Web Router
|
||||
version: 0.1.0
|
||||
description: Connect explicitly published websites to your own public gateway. HTTPS keys stay on this node. Configure routes through Setup.
|
||||
container:
|
||||
network: slirp4netns
|
||||
build:
|
||||
context: /opt/archipelago/docker/public-web-router
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-public-web-router:0.1.0
|
||||
dependencies:
|
||||
- storage: 256Mi
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 256m
|
||||
disk_limit: 512Mi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
seccomp_profile: default
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/public-web-router/data
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/public-web-router/config
|
||||
target: /config
|
||||
options: [ro]
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
options: [rw, nosuid, nodev, size=16m]
|
||||
health_check:
|
||||
type: exec
|
||||
endpoint: python3 -c "import pathlib,time; assert time.time()-pathlib.Path('/tmp/router/heartbeat').stat().st_mtime < 30"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
metadata:
|
||||
author: Archipelago
|
||||
category: networking
|
||||
tier: optional
|
||||
license: Apache-2.0 / MIT
|
||||
icon: /assets/img/app-icons/nginx.svg
|
||||
tags: [networking, websites, privacy]
|
||||
@@ -219,3 +219,6 @@ app:
|
||||
nostr_integration:
|
||||
relay_type: public
|
||||
monetization_enabled: true
|
||||
|
||||
metadata:
|
||||
guest_access: true
|
||||
|
||||
Generated
+80
-5
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.22-alpha"
|
||||
version = "1.9.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
@@ -137,9 +137,11 @@ dependencies = [
|
||||
"hyper 0.14.32",
|
||||
"hyper-util",
|
||||
"hyper-ws-listener",
|
||||
"image",
|
||||
"iroh",
|
||||
"iroh-blobs",
|
||||
"libc",
|
||||
"lightning-invoice",
|
||||
"lofty",
|
||||
"mainline",
|
||||
"mdns-sd",
|
||||
@@ -228,6 +230,22 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archipelago-publishing-tests"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"hyper 0.14.32",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archipelago-security"
|
||||
version = "0.1.0"
|
||||
@@ -1567,6 +1585,15 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
@@ -2503,8 +2530,22 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"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]]
|
||||
@@ -3636,9 +3677,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr"
|
||||
version = "0.44.2"
|
||||
version = "0.44.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
|
||||
checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
@@ -3681,9 +3722,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-relay-pool"
|
||||
version = "0.44.0"
|
||||
version = "0.44.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b"
|
||||
checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"async-wsocket",
|
||||
@@ -4142,6 +4183,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
@@ -4366,6 +4420,12 @@ dependencies = [
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -7322,3 +7382,18 @@ dependencies = [
|
||||
"log",
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
"openwrt",
|
||||
"performance",
|
||||
"security",
|
||||
"publishing-tests",
|
||||
]
|
||||
|
||||
# Shared package metadata, inherited by each member via `license.workspace = true`.
|
||||
@@ -27,3 +28,7 @@ opt-level = 3
|
||||
|
||||
# Archipelago workspace - no StartOS dependencies
|
||||
# All patches removed - we use standard crates.io dependencies
|
||||
|
||||
# Small source-sharing validation harness; no optimized tests needed.
|
||||
[profile.test.package.archipelago-publishing-tests]
|
||||
opt-level = 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.22-alpha"
|
||||
version = "1.9.0-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
@@ -73,6 +73,7 @@ chrono = "0.4"
|
||||
|
||||
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
|
||||
bip39 = { version = "2.1", features = ["rand"] }
|
||||
lightning-invoice = "=0.34.1"
|
||||
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
|
||||
|
||||
# Configuration
|
||||
@@ -106,6 +107,8 @@ flate2 = "1.0"
|
||||
# TOTP 2FA
|
||||
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
|
||||
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"
|
||||
zeroize = { version = "1.8.2", features = ["derive"] }
|
||||
|
||||
@@ -146,6 +149,7 @@ iroh-blobs = { version = "0.103", optional = true }
|
||||
lofty = "0.24.0"
|
||||
cashu = { version = "0.17.5", default-features = false, features = ["wallet"] }
|
||||
|
||||
tempfile = "3.10"
|
||||
|
||||
[dev-dependencies]
|
||||
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))?)
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,48 @@ use hyper::{Response, StatusCode};
|
||||
use super::{is_valid_app_id, ApiHandler};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match content_server::load_catalog(&config.data_dir).await {
|
||||
fn verified_content_peer(
|
||||
&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) => {
|
||||
// Only expose public metadata for available items
|
||||
let items: Vec<serde_json::Value> = catalog
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
||||
.filter(|item| content_server::visible_to(item, peer.as_deref(), known, owner))
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"id": i.id,
|
||||
@@ -74,7 +108,7 @@ impl ApiHandler {
|
||||
let invoice_hash = headers
|
||||
.get("x-invoice-hash")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-onchain-address")
|
||||
@@ -82,11 +116,18 @@ impl ApiHandler {
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
// Extract federation peer DID from X-Federation-DID header
|
||||
let peer_did = headers
|
||||
.get("x-federation-did")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let peer_did = match self.verified_content_peer(path, headers) {
|
||||
Ok(peer) => peer,
|
||||
Err(_) => {
|
||||
return Ok(build_response(
|
||||
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
|
||||
// content: validate the session cookie (same discipline as the model
|
||||
@@ -98,11 +139,64 @@ impl ApiHandler {
|
||||
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
|
||||
let range = headers
|
||||
.get("range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(content_server::parse_range_header);
|
||||
let range = match headers.get("range") {
|
||||
None => None,
|
||||
Some(value) => match value
|
||||
.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(
|
||||
&config.data_dir,
|
||||
@@ -115,6 +209,7 @@ impl ApiHandler {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(content_server::ServeResult::Stream(body)) => body.into_response(),
|
||||
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
@@ -195,7 +290,11 @@ impl ApiHandler {
|
||||
/// 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.
|
||||
/// 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
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/invoice"))
|
||||
@@ -208,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)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -221,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 {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
|
||||
_ => {
|
||||
@@ -242,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}");
|
||||
match self
|
||||
.rpc_handler
|
||||
@@ -249,7 +368,13 @@ impl ApiHandler {
|
||||
.await
|
||||
{
|
||||
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!({
|
||||
"bolt11": bolt11,
|
||||
"payment_hash": payment_hash,
|
||||
@@ -290,58 +415,20 @@ impl ApiHandler {
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
||||
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
|
||||
Some((id, hash)) => (id, hash),
|
||||
None => {
|
||||
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()),
|
||||
))
|
||||
Ok(invoice_status_response(path, |hash, id| async move {
|
||||
self.rpc_handler.content_invoice_lifecycle(&hash, &id).await
|
||||
})
|
||||
.await)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/onchain"))
|
||||
@@ -353,43 +440,80 @@ impl ApiHandler {
|
||||
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)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => match &i.access {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||
if !content_server::method_accepted(&i.access, "onchain") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
*price_sats
|
||||
}
|
||||
_ => {
|
||||
let Some(item) = catalog.items.iter().find(|item| item.id == content_id) else {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
));
|
||||
};
|
||||
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 {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||
if !content_server::method_accepted(&item.access, "onchain") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"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"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
*price_sats
|
||||
}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
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 {
|
||||
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!({
|
||||
"address": address,
|
||||
"amount_sats": price_sats,
|
||||
@@ -439,7 +563,7 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
// 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,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
@@ -450,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 let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
||||
crate::content_invoice::mark_paid(address).await;
|
||||
paid = true;
|
||||
match self.rpc_handler.onchain_received(address, price).await {
|
||||
Ok(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 });
|
||||
@@ -485,6 +622,7 @@ impl ApiHandler {
|
||||
}
|
||||
|
||||
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)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
@@ -531,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)?),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
mod blob;
|
||||
mod cdp;
|
||||
mod cloud_purchase;
|
||||
mod content;
|
||||
mod dwn;
|
||||
pub(crate) mod lightning_purchase;
|
||||
mod model_proxy;
|
||||
mod node_message;
|
||||
pub(crate) mod onchain_purchase;
|
||||
mod proxy;
|
||||
mod purchase;
|
||||
mod registered_media;
|
||||
mod remote_input;
|
||||
mod remote_relay;
|
||||
mod rental_playback;
|
||||
mod routstr_proxy;
|
||||
mod terminal;
|
||||
mod websocket;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
@@ -384,6 +391,12 @@ impl ApiHandler {
|
||||
let path = req.uri().path().to_string();
|
||||
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
|
||||
if method == Method::OPTIONS {
|
||||
let mut builder = Response::builder()
|
||||
@@ -414,6 +427,16 @@ impl ApiHandler {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Owner terminal attachment — the browser socket is disposable; the
|
||||
// authenticated tmux session survives reconnects and browser closes.
|
||||
if method == Method::GET && path == "/ws/terminal" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_terminal_websocket(req).await;
|
||||
}
|
||||
|
||||
// Remote input WebSocket — companion app sends keyboard/mouse events
|
||||
if method == Method::GET && path == "/ws/remote-input" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
@@ -444,6 +467,32 @@ impl ApiHandler {
|
||||
.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
|
||||
let headers = req.headers().clone();
|
||||
let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default();
|
||||
@@ -506,6 +555,15 @@ impl ApiHandler {
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
(Method::GET, "/api/terminal/sessions") => {
|
||||
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
|
||||
terminal::list_response().await
|
||||
}
|
||||
(Method::POST, "/api/terminal/sessions") => {
|
||||
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
|
||||
terminal::create(&body_bytes).await
|
||||
}
|
||||
|
||||
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
||||
(Method::POST, "/archipelago/node-message") => {
|
||||
Self::handle_node_message(body_bytes).await
|
||||
@@ -584,6 +642,15 @@ impl ApiHandler {
|
||||
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)
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
|
||||
Self::handle_content_preview(p, &self.config).await
|
||||
@@ -591,7 +658,7 @@ impl ApiHandler {
|
||||
|
||||
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
|
||||
(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/") => {
|
||||
self.handle_content_invoice_status(p).await
|
||||
@@ -602,7 +669,7 @@ impl ApiHandler {
|
||||
self.handle_content_onchain_status(p).await
|
||||
}
|
||||
(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);
|
||||
@@ -612,7 +679,7 @@ impl ApiHandler {
|
||||
}
|
||||
|
||||
// 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)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
@@ -623,6 +690,34 @@ impl ApiHandler {
|
||||
// (upstream Gitea has no ACAO header) or CSP (IP-port upstream
|
||||
// falls outside `connect-src`). Session-authenticated so only
|
||||
// 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") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -238,54 +238,13 @@ impl ApiHandler {
|
||||
return bad("invalid onion or content id");
|
||||
}
|
||||
|
||||
// Already purchased? Serve the local cache — no network, no
|
||||
// re-payment. The seller's node charges every fetch by design; the
|
||||
// buyer-side store (content_owned) exists precisely so an owned item
|
||||
// never has to be bought twice, and the content surface's cards were
|
||||
// hitting the seller's 402 and rendering as permanent placeholders.
|
||||
// Range is honoured by slicing, so seek/playback works from cache.
|
||||
if crate::content_owned::is_owned(&self.config.data_dir, onion, content_id).await {
|
||||
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).
|
||||
// Ownership is checked before opening a bounded file stream. Corrupt
|
||||
// records or missing purchased bytes never trigger another purchase.
|
||||
match crate::content_owned::open_owned(&self.config.data_dir, onion, content_id).await {
|
||||
Ok(Some((mime, file))) => return crate::media_stream::file_response(file, &mime, headers).await,
|
||||
Ok(None) => {},
|
||||
Err(_) => return Ok(build_response(StatusCode::CONFLICT, "application/json",
|
||||
hyper::Body::from(serde_json::json!({"error": "Purchased file unavailable locally. Recover the existing purchase without paying again."}).to_string()))),
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
@@ -293,20 +252,31 @@ impl ApiHandler {
|
||||
// Generous overall timeout: this endpoint serves both seek/Range
|
||||
// playback (small, finishes fast) and full-file downloads of large
|
||||
// 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)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.require_fips()
|
||||
.record_transport(&self.config.data_dir)
|
||||
.timeout(std::time::Duration::from_secs(900));
|
||||
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("Range", r.to_string());
|
||||
}
|
||||
let req = req.authenticate_content(&self.config.data_dir).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 rh = resp.headers().clone();
|
||||
let mut builder = Response::builder()
|
||||
.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"] {
|
||||
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,17 @@ async fn forward_models() -> Result<Response<Body>> {
|
||||
/// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05,
|
||||
/// offline) → price quote → pay → forward → redeem change → record net.
|
||||
async fn forward_chat(req: Request<Body>, data_dir: &Path) -> Result<Response<Body>> {
|
||||
// An already-open iframe may still show its previous selection. The node's
|
||||
// saved choice is authoritative before any pricing, token or network work.
|
||||
let settings = crate::settings::model_provider::ModelProvider::load(data_dir).await?;
|
||||
if settings.provider != crate::settings::model_provider::Provider::Routstr {
|
||||
return Ok(json_response(
|
||||
StatusCode::CONFLICT,
|
||||
json!({"error": {
|
||||
"code": "provider_changed", "message": "Your AI provider changed. Reopen AIUI before sending this request."
|
||||
}}),
|
||||
));
|
||||
}
|
||||
let payload = hyper::body::to_bytes(req.into_body())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
|
||||
@@ -445,6 +456,41 @@ mod tests {
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_routstr_selection_cannot_pay_after_provider_change() {
|
||||
let store = test_store().await;
|
||||
let token = store.create().await;
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
crate::settings::model_provider::ModelProvider {
|
||||
provider: crate::settings::model_provider::Provider::Claude,
|
||||
openai_model: String::new(),
|
||||
}
|
||||
.save(data_dir.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let r = req(
|
||||
"POST",
|
||||
"/aiui/api/routstr/chat/completions",
|
||||
Some(&token),
|
||||
"{}",
|
||||
);
|
||||
let response = route_routstr_proxy(
|
||||
&store,
|
||||
data_dir.path(),
|
||||
r,
|
||||
"/aiui/api/routstr/chat/completions",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
assert_eq!(
|
||||
crate::assistant::AssistantBudget::load(data_dir.path())
|
||||
.await
|
||||
.spent_sats,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// D-05: a fresh node (no budget file → zero allowance) refuses the paid
|
||||
/// path BEFORE any pricing/network I/O — this test runs fully offline.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Owner-authenticated terminal sessions backed by private tmux processes.
|
||||
//!
|
||||
//! Browser connections are disposable attachments. The tmux process and its
|
||||
//! metadata remain on the node so a reconnect resumes the same workspace.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::process::Command;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
fn tmux_key_for_input(data: &str) -> Option<&'static str> {
|
||||
match data {
|
||||
"\u{3}" => Some("C-c"),
|
||||
"\u{4}" => Some("C-d"),
|
||||
"\r" | "\n" => Some("Enter"),
|
||||
"\u{7f}" => Some("BSpace"),
|
||||
"\t" => Some("Tab"),
|
||||
"\u{1b}[A" => Some("Up"),
|
||||
"\u{1b}[B" => Some("Down"),
|
||||
"\u{1b}[C" => Some("Right"),
|
||||
"\u{1b}[D" => Some("Left"),
|
||||
"\u{1b}[H" => Some("Home"),
|
||||
"\u{1b}[F" => Some("End"),
|
||||
"\u{1b}[3~" => Some("DC"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{build_response, ApiHandler};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct SessionRecord {
|
||||
pub schema: u8,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub workspace: String,
|
||||
pub state: String,
|
||||
pub tmux: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateRequest {
|
||||
name: Option<String>,
|
||||
workspace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClientMessage {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
data: Option<String>,
|
||||
cols: Option<u16>,
|
||||
rows: Option<u16>,
|
||||
}
|
||||
|
||||
pub(crate) fn state_dir() -> PathBuf {
|
||||
std::env::var_os("ARCHY_SESSION_STATE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
if let Some(xdg) = std::env::var_os("XDG_STATE_HOME") {
|
||||
PathBuf::from(xdg).join("archipelago/sessions")
|
||||
} else if let Some(home) = std::env::var_os("HOME") {
|
||||
let user_dir = PathBuf::from(home).join(".local/state/archipelago/sessions");
|
||||
if user_dir.exists() {
|
||||
user_dir
|
||||
} else {
|
||||
PathBuf::from("/var/lib/archipelago/sessions")
|
||||
}
|
||||
} else {
|
||||
PathBuf::from("/var/lib/archipelago/sessions")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
async fn read_record(dir: &Path, id: &str) -> Result<SessionRecord> {
|
||||
if !valid_id(id) {
|
||||
return Err(anyhow!("invalid session id"));
|
||||
}
|
||||
let bytes = tokio::fs::read(dir.join(format!("{id}.json"))).await?;
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
async fn tmux_alive(name: &str) -> bool {
|
||||
Command::new("tmux")
|
||||
.args(["has-session", "-t", name])
|
||||
.output()
|
||||
.await
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn write_record(dir: &Path, record: &SessionRecord) -> Result<()> {
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
let tmp = dir.join(format!(".{}.tmp-{}", record.id, Uuid::new_v4()));
|
||||
let final_path = dir.join(format!("{}.json", record.id));
|
||||
tokio::fs::write(&tmp, serde_json::to_vec_pretty(record)?).await?;
|
||||
tokio::fs::rename(tmp, final_path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list(dir: &Path) -> Result<Vec<SessionRecord>> {
|
||||
let mut out = Vec::new();
|
||||
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = tokio::fs::read(entry.path()).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut record) = serde_json::from_slice::<SessionRecord>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
record.state = if tmux_alive(&record.tmux).await {
|
||||
"detached".into()
|
||||
} else if record.state == "running" {
|
||||
"interrupted".into()
|
||||
} else {
|
||||
record.state.clone()
|
||||
};
|
||||
out.push(record);
|
||||
}
|
||||
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_response() -> Result<Response<hyper::Body>> {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(serde_json::to_vec(
|
||||
&list(&state_dir()).await?,
|
||||
)?))?)
|
||||
}
|
||||
|
||||
pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
|
||||
let request: CreateRequest = serde_json::from_slice(body).unwrap_or(CreateRequest {
|
||||
name: None,
|
||||
workspace: None,
|
||||
});
|
||||
let workspace_was_requested = request.workspace.is_some();
|
||||
let workspace = request.workspace.unwrap_or_else(|| {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("Work")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
let workspace_path = PathBuf::from(&workspace);
|
||||
if !workspace_was_requested {
|
||||
tokio::fs::create_dir_all(&workspace_path).await?;
|
||||
}
|
||||
if !workspace_path.is_absolute() || !workspace_path.is_dir() || workspace_path == Path::new("/")
|
||||
{
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"workspace must be an existing non-root directory"}"#),
|
||||
));
|
||||
}
|
||||
let id = format!("s-{}", Uuid::new_v4().simple());
|
||||
let tmux_name = format!("archy-{id}");
|
||||
let output = Command::new("tmux")
|
||||
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"tmux could not start the session"}"#),
|
||||
));
|
||||
}
|
||||
let name = request
|
||||
.name
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.unwrap_or_else(|| "Work".into());
|
||||
let record = SessionRecord {
|
||||
schema: 1,
|
||||
id,
|
||||
name,
|
||||
workspace,
|
||||
state: "running".into(),
|
||||
tmux: tmux_name,
|
||||
updated_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
write_record(&state_dir(), &record).await?;
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(serde_json::to_vec(&record)?))?)
|
||||
}
|
||||
|
||||
pub(crate) async fn websocket(req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||
let id = req
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|query| {
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|part| part.strip_prefix("session="))
|
||||
})
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let record = read_record(&state_dir(), &id).await?;
|
||||
if !tmux_alive(&record.tmux).await {
|
||||
return Ok(build_response(
|
||||
StatusCode::CONFLICT,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"session is not running"}"#),
|
||||
));
|
||||
}
|
||||
let (response, ws_fut) =
|
||||
hyper_ws_listener::create_ws(req).map_err(|e| anyhow!("WebSocket upgrade failed: {e}"))?;
|
||||
if let Some(ws_fut) = ws_fut {
|
||||
tokio::spawn(async move {
|
||||
let Ok(Ok(stream)) = ws_fut.await else { return };
|
||||
let (mut tx, mut rx) = stream.split();
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(150));
|
||||
let mut last = String::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
// Capture the visible pane only. Asking tmux for a large
|
||||
// historical range injects hundreds of blank rows into
|
||||
// xterm on every reconnect, producing a misleading
|
||||
// giant initial scrollbar. xterm owns live scrollback.
|
||||
let output = Command::new("tmux").args(["capture-pane", "-p", "-e", "-t", &record.tmux]).output().await;
|
||||
if let Ok(output) = output {
|
||||
let text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
if text != last { last = text.clone(); if tx.send(Message::Text(serde_json::json!({"type":"output", "data":text}).to_string())).await.is_err() { break; } }
|
||||
} else { break; }
|
||||
}
|
||||
message = rx.next() => match message {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let Ok(message) = serde_json::from_str::<ClientMessage>(&text) else { continue };
|
||||
match message.kind.as_str() {
|
||||
"input" => if let Some(data) = message.data { let mut command = Command::new("tmux"); command.args(["send-keys", "-t", &record.tmux]); if let Some(key) = tmux_key_for_input(&data) { command.arg(key); } else { command.args(["-l", "--", &data]); } let _ = command.output().await; },
|
||||
"resize" => if let (Some(cols), Some(rows)) = (message.cols, message.rows) { let _ = Command::new("tmux").args(["resize-window", "-t", &record.tmux, "-x", &cols.to_string(), "-y", &rows.to_string()]).output().await; },
|
||||
"ping" => { let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.into())).await; },
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Err(_)) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_terminal_websocket(
|
||||
req: Request<hyper::Body>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
websocket(req).await
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,47 @@
|
||||
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashSet;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
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 {
|
||||
/// Check if analytics are enabled.
|
||||
pub(super) async fn handle_analytics_get_status(&self) -> Result<serde_json::Value> {
|
||||
@@ -262,7 +299,7 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<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
|
||||
let node_id = report
|
||||
@@ -285,6 +322,13 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.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");
|
||||
tokio::fs::create_dir_all(&fleet_dir)
|
||||
.await
|
||||
@@ -339,9 +383,9 @@ impl RpcHandler {
|
||||
let mut nodes: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// ── Trusted federation nodes ─────────────────────────────────────
|
||||
let fed_nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let fed_nodes = crate::federation::load_nodes(&self.config.data_dir).await?;
|
||||
let mut authoritative = federation_ids(&self.config.data_dir).await?;
|
||||
authoritative.extend(fed_nodes.iter().map(|n| n.did.clone()));
|
||||
for n in fed_nodes
|
||||
.iter()
|
||||
.filter(|n| n.trust_level == crate::federation::TrustLevel::Trusted)
|
||||
@@ -352,20 +396,19 @@ impl RpcHandler {
|
||||
(Some(u), Some(t)) if t > 0 => {
|
||||
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 reported_at = state
|
||||
.map(|s| s.timestamp.clone())
|
||||
.or_else(|| n.last_seen.clone())
|
||||
.unwrap_or_else(|| n.added_at.clone());
|
||||
.or_else(|| n.last_seen.clone());
|
||||
|
||||
let mut report = serde_json::json!({
|
||||
"node_id": n.did,
|
||||
"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),
|
||||
"cpu_pct": state.and_then(|s| s.cpu_usage_percent).map(|v| v.round()).unwrap_or(0.0),
|
||||
"uptime_secs": state.and_then(|s| s.uptime_secs),
|
||||
"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)),
|
||||
"disk_pct": pct(state.and_then(|s| s.disk_used_bytes), state.and_then(|s| s.disk_total_bytes)),
|
||||
"container_count": apps.len(),
|
||||
@@ -379,6 +422,7 @@ impl RpcHandler {
|
||||
"reported_at": reported_at,
|
||||
"trust_level": n.trust_level.to_string(),
|
||||
"source": "federation",
|
||||
"identity_authenticated": true,
|
||||
});
|
||||
annotate_fleet_report(&mut report);
|
||||
nodes.push(report);
|
||||
@@ -401,9 +445,20 @@ impl RpcHandler {
|
||||
|
||||
match tokio::fs::read_to_string(entry.path()).await {
|
||||
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
|
||||
Ok(mut report) => {
|
||||
annotate_fleet_report(&mut report);
|
||||
nodes.push(report);
|
||||
Ok(report) => {
|
||||
if let Ok(mut 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) => {
|
||||
warn!(file = %name, error = %e, "Skipping corrupt fleet report");
|
||||
@@ -450,6 +505,14 @@ impl RpcHandler {
|
||||
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
|
||||
.config
|
||||
.data_dir
|
||||
@@ -461,8 +524,15 @@ impl RpcHandler {
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let history: Vec<_> = history
|
||||
.into_iter()
|
||||
.filter_map(|report| collector_report(report, Some(node_id)).ok())
|
||||
.collect();
|
||||
Ok(serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"history_available": true,
|
||||
"source": "collector",
|
||||
"identity_authenticated": false,
|
||||
"entries": history,
|
||||
"count": history.len(),
|
||||
}))
|
||||
@@ -476,6 +546,7 @@ impl RpcHandler {
|
||||
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 entries = tokio::fs::read_dir(&fleet_dir)
|
||||
.await
|
||||
@@ -498,19 +569,30 @@ impl RpcHandler {
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let report = match collector_report(report, name.strip_suffix(".json")) {
|
||||
Ok(report) => report,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let node_id = report
|
||||
.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
if authoritative.contains(&node_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(alerts) = report.get("recent_alerts").and_then(|v| v.as_array()) {
|
||||
for alert in alerts {
|
||||
let mut enriched = alert.clone();
|
||||
if let Some(obj) = enriched.as_object_mut() {
|
||||
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
|
||||
.map(|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);
|
||||
|
||||
@@ -569,7 +651,9 @@ fn annotate_fleet_report(report: &mut serde_json::Value) {
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
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()
|
||||
} else if mins < 60 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ impl RpcHandler {
|
||||
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||
/// 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,
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,24 @@ async fn files_copy_routes_media_and_sanitizes_the_filename() {
|
||||
("audio/mpeg", "Music"),
|
||||
("text/plain", "Documents"),
|
||||
] {
|
||||
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
|
||||
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 #?")));
|
||||
@@ -47,11 +64,26 @@ async fn files_copy_routes_media_and_sanitizes_the_filename() {
|
||||
#[tokio::test]
|
||||
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -71,3 +103,333 @@ fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
@@ -11,6 +11,26 @@ impl RpcHandler {
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match method {
|
||||
"publishing.gateway-app-route" => {
|
||||
self.handle_publishing_gateway_app_route(params).await
|
||||
}
|
||||
"publishing.gateway-configure" => {
|
||||
self.handle_publishing_gateway_configure(params).await
|
||||
}
|
||||
"publishing.gateway-route" => self.handle_publishing_gateway_route(params).await,
|
||||
"publishing.gateway-disconnect" => {
|
||||
crate::publishing::gateway::disconnect(&self.config.data_dir).await
|
||||
}
|
||||
"publishing.status" => self.handle_publishing_status().await,
|
||||
"publishing.verify-https" => self.handle_publishing_verify_https(params).await,
|
||||
"publishing.update" => self.handle_publishing_update(params).await,
|
||||
"publishing.dns" => self.handle_publishing_dns(params).await,
|
||||
"publishing.generate" => self.handle_publishing_generate(params).await,
|
||||
"publishing.nsite-prepare" => self.handle_publishing_nsite_prepare(params).await,
|
||||
"publishing.blossom-prepare" => self.handle_publishing_blossom_prepare(params).await,
|
||||
"publishing.blossom-store" => self.handle_publishing_blossom_store(params).await,
|
||||
"publishing.access-create" => self.handle_publishing_access_create(params).await,
|
||||
"publishing.access-revoke" => self.handle_publishing_access_revoke(params).await,
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"server.get-state" => self.handle_server_get_state().await,
|
||||
@@ -132,6 +152,9 @@ impl RpcHandler {
|
||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(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.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
@@ -156,6 +179,8 @@ impl RpcHandler {
|
||||
|
||||
// Multi-identity management
|
||||
"identity.list" => self.handle_identity_list(params).await,
|
||||
"identity.capabilities" => Ok(serde_json::json!({"import_nostr":true})),
|
||||
"identity.import-nostr" => self.handle_identity_import_nostr(params).await,
|
||||
"identity.create" => self.handle_identity_create(params).await,
|
||||
"identity.get" => self.handle_identity_get(params).await,
|
||||
"identity.delete" => self.handle_identity_delete(params).await,
|
||||
@@ -319,6 +344,8 @@ impl RpcHandler {
|
||||
// Content catalog management
|
||||
"content.list-mine" => self.handle_content_list_mine().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.set-pricing" => self.handle_content_set_pricing(params).await,
|
||||
"content.set-availability" => self.handle_content_set_availability(params).await,
|
||||
@@ -327,6 +354,43 @@ impl RpcHandler {
|
||||
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
||||
"content.indeehub-projects" => self.handle_content_indeehub_projects().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-get" => self.handle_content_owned_get(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::nostr_handshake;
|
||||
use anyhow::Result;
|
||||
use futures_util::stream::{FuturesUnordered, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
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 node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
let mut synced = 0u32;
|
||||
let mut failed = 0u32;
|
||||
// A dead Tor peer can take the bounded transport timeout. Serialising
|
||||
// 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();
|
||||
|
||||
for node in &nodes {
|
||||
if node.trust_level == TrustLevel::Untrusted {
|
||||
continue;
|
||||
}
|
||||
|
||||
let did_clone = local_did.clone();
|
||||
match federation::sync_with_peer(&self.config.data_dir, node, &did_clone, |bytes| {
|
||||
node_identity.sign(bytes)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(state) => {
|
||||
synced += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "ok",
|
||||
"apps": state.apps.len(),
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "error",
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
}
|
||||
while let Some((index, did, result)) = pending.next().await {
|
||||
let row = match result {
|
||||
Ok(state) => serde_json::json!({
|
||||
"index": index,
|
||||
"did": did,
|
||||
"status": "ok",
|
||||
"apps": state.apps.len(),
|
||||
}),
|
||||
Err(e) => serde_json::json!({
|
||||
"index": index,
|
||||
"did": did,
|
||||
"status": "error",
|
||||
"error": e.to_string(),
|
||||
}),
|
||||
};
|
||||
results.push(row);
|
||||
}
|
||||
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;
|
||||
for row in &mut results {
|
||||
if let Some(object) = row.as_object_mut() {
|
||||
object.remove("index");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,14 +602,39 @@ impl RpcHandler {
|
||||
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(
|
||||
apps,
|
||||
0.0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
metrics
|
||||
.map(|m| m.cpu_percent)
|
||||
.filter(|v| v.is_finite() && (0.0..=100.0).contains(v)),
|
||||
metrics
|
||||
.filter(|m| m.mem_total_bytes > 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,
|
||||
server_name,
|
||||
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 local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let local_onion = data
|
||||
.server_info
|
||||
.tor_address
|
||||
.clone()
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Tor address not available"))?;
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
// Generate a one-shot federation invite. The code embeds OUR onion
|
||||
// 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 local_fips_npub = identity::fips_npub(&self.config.data_dir.join("identity"))
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let invite_code = federation::create_invite(
|
||||
&self.config.data_dir,
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_onion,
|
||||
&data.server_info.pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
TrustLevel::Observer,
|
||||
)
|
||||
.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
|
||||
// when their `federation.peer-joined` callback arrives over Tor we
|
||||
// already trust their pubkey enough to accept the join. Their DID
|
||||
// and pubkey come from the request — we'll cross-check the pubkey
|
||||
// against the eventual peer-joined signature in the existing
|
||||
// verification path (handlers.rs line ~365).
|
||||
if !req.from_did.is_empty() {
|
||||
// We don't know the requester's onion or ed25519 pubkey yet —
|
||||
// they'll send those in the federation.peer-joined callback
|
||||
// after they apply our invite. Until then we can't add a real
|
||||
// FederatedNode entry. We just store the pending row as
|
||||
// Approved so the UI shows progress, and trust the existing
|
||||
// peer-joined handler to admit them as Observer when they call.
|
||||
//
|
||||
// Caveat: peer-joined currently hardcodes TrustLevel::Trusted.
|
||||
// We override that below by demoting on success.
|
||||
debug!(
|
||||
requester_did = %req.from_did,
|
||||
"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,
|
||||
async fn deliver_peer_approval_reply(
|
||||
&self,
|
||||
reply: &federation::handshake_delivery::ApprovalReply,
|
||||
) -> Result<bool> {
|
||||
let Some(claimed) = federation::handshake_delivery::claim(
|
||||
&self.config.data_dir,
|
||||
&reply.request_id,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let result = nostr_handshake::send_peer_invite(
|
||||
&self.config.data_dir.join("identity"),
|
||||
&claimed.recipient,
|
||||
&claimed.invite_code,
|
||||
&self.handshake_relays().await,
|
||||
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?;
|
||||
info!(
|
||||
id = %id,
|
||||
from = %req.from_nostr_pubkey,
|
||||
"Approved peer request and shipped invite over NIP-44"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"approved": true,
|
||||
"id": id,
|
||||
}))
|
||||
/// Recover relay loss and legacy approvals without changing trust or
|
||||
/// resurrecting a node that the operator explicitly removed.
|
||||
pub(in crate::api::rpc) async fn retry_peer_approval_replies(&self) -> Result<()> {
|
||||
let requests = pending::load_pending(&self.config.data_dir).await?;
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
let removed = federation::load_removed_dids(&self.config.data_dir).await?;
|
||||
let cutoff = chrono::Utc::now() - chrono::Duration::days(30);
|
||||
let mut sent = 0;
|
||||
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,
|
||||
@@ -1323,19 +1422,19 @@ impl RpcHandler {
|
||||
);
|
||||
}
|
||||
|
||||
pending::decide(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
|
||||
if notify {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let _ = nostr_handshake::send_peer_reject(
|
||||
&identity_dir,
|
||||
&req.from_nostr_pubkey,
|
||||
reason,
|
||||
&self.config.nostr_relays,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pending::set_state(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
|
||||
info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request");
|
||||
Ok(serde_json::json!({ "rejected": true, "id": id }))
|
||||
}
|
||||
@@ -1361,16 +1460,7 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
let req = pending::find_by_id(&self.config.data_dir, id)
|
||||
.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
|
||||
);
|
||||
}
|
||||
let req = pending::cancel_outbound(&self.config.data_dir, id).await?;
|
||||
|
||||
if notify {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
@@ -1380,7 +1470,7 @@ impl RpcHandler {
|
||||
&identity_dir,
|
||||
&req.from_nostr_pubkey,
|
||||
reason,
|
||||
&self.config.nostr_relays,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.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");
|
||||
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;
|
||||
#[cfg(test)]
|
||||
mod handshake_tests;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
|
||||
@@ -276,6 +276,11 @@ impl RpcHandler {
|
||||
}
|
||||
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> {
|
||||
@@ -340,6 +345,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
HandshakeMessage::PeerInvite { invite_code } => {
|
||||
let _decision = pending::outbound_decision_guard().await;
|
||||
// Match against an outbound Sent request from this nostr
|
||||
// pubkey. If we never sent them anything, ignore — we
|
||||
// don't accept unsolicited invites over Nostr.
|
||||
@@ -356,6 +362,16 @@ impl RpcHandler {
|
||||
);
|
||||
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 (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did =
|
||||
@@ -373,7 +389,7 @@ impl RpcHandler {
|
||||
let local_name = data.server_info.name.clone();
|
||||
match crate::federation::accept_invite(
|
||||
&self.config.data_dir,
|
||||
invite_code,
|
||||
&scoped_invite,
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
@@ -416,10 +432,11 @@ impl RpcHandler {
|
||||
.await;
|
||||
}
|
||||
|
||||
pending::set_state(
|
||||
pending::complete_outbound(
|
||||
&self.config.data_dir,
|
||||
&row_id,
|
||||
PendingState::Approved,
|
||||
&hs.from_nostr_pubkey,
|
||||
&node.did,
|
||||
)
|
||||
.await?;
|
||||
applied_invites.push(node.did);
|
||||
@@ -434,6 +451,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
HandshakeMessage::PeerReject { reason } => {
|
||||
let _decision = pending::outbound_decision_guard().await;
|
||||
let pendings = pending::load_pending(&self.config.data_dir).await?;
|
||||
if let Some(row) = pendings.iter().find(|r| {
|
||||
r.outbound
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
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 anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
@@ -38,7 +40,7 @@ impl RpcHandler {
|
||||
.into_iter()
|
||||
.map(|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 {
|
||||
(
|
||||
node_nostr_hex.clone().or(id.nostr_pubkey),
|
||||
@@ -110,6 +112,25 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Explicit owner-key import into a separate native business identity.
|
||||
pub(in crate::api::rpc) async fn handle_identity_import_nostr(
|
||||
&self, params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let password = params.get("password").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Invalid node password");
|
||||
}
|
||||
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Just Works");
|
||||
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name");
|
||||
let nsec = params.get("nsec").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let npub = params.get("expected_npub").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let record = manager.import_nostr(name.to_string(), nsec, npub).await?;
|
||||
Ok(serde_json::json!({"id":record.id, "name":record.name,
|
||||
"nostr_npub":record.nostr_npub, "nostr_pubkey":record.nostr_pubkey}))
|
||||
}
|
||||
|
||||
/// Get a single identity by ID.
|
||||
pub(in crate::api::rpc) async fn handle_identity_get(
|
||||
&self,
|
||||
|
||||
@@ -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, ¶ms.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,
|
||||
¶ms.content_id,
|
||||
)
|
||||
.await?;
|
||||
if matches!(action, "create" | "pay" | "retry") || params.external_exposure {
|
||||
self.ensure_onchain_allows_other_rail(&buyer, &peer.did, ¶ms.content_id)
|
||||
.await?;
|
||||
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
cashu
|
||||
.find_buyers(&buyer, &peer.did, ¶ms.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) = ¶ms.operation_id {
|
||||
journal.buyer(id)?
|
||||
} else {
|
||||
journal.buyer_for(&buyer, &peer.did, ¶ms.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), ¶ms.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(())
|
||||
}
|
||||
}
|
||||
@@ -272,28 +272,8 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
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"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||
|
||||
info!(
|
||||
peer = pubkey,
|
||||
@@ -473,6 +453,7 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let fee_query = close_channel_fee_query(¶ms)?;
|
||||
let force = params
|
||||
.get("force")
|
||||
.and_then(|v| v.as_bool())
|
||||
@@ -498,13 +479,11 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
|
||||
let url = format!(
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]);
|
||||
|
||||
let mut resp = client
|
||||
.delete(&url)
|
||||
.query(&fee_query)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.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(¶ms).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(¤t["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("e, &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(¶ms.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());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
mod channels;
|
||||
pub(super) mod external_invoice;
|
||||
mod fee_bump;
|
||||
mod fee_policy;
|
||||
mod info;
|
||||
mod macaroons;
|
||||
pub(super) mod onchain_purchase;
|
||||
mod payments;
|
||||
mod seed_backup;
|
||||
mod wallet;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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> {
|
||||
value.get(key).and_then(|v| {
|
||||
v.as_str()
|
||||
@@ -190,33 +217,7 @@ impl RpcHandler {
|
||||
return Err(payment_error(msg));
|
||||
}
|
||||
let payment = body.get("result").unwrap_or(&body);
|
||||
match payment.get("status").and_then(|v| v.as_str()).unwrap_or("") {
|
||||
"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,
|
||||
}))
|
||||
Ok(router_payment_outcome(payment, &decoded_hash, decoded_amt))
|
||||
}
|
||||
|
||||
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
||||
@@ -244,6 +245,10 @@ impl RpcHandler {
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"LND payment status is unavailable"
|
||||
);
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.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)
|
||||
transactions.sort_by(|a, b| {
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn router_failure_reasons_are_actionable() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -7,6 +7,57 @@ use zeroize::Zeroize;
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
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.
|
||||
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| {
|
||||
@@ -124,28 +175,8 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
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"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||
|
||||
info!(
|
||||
addr = addr,
|
||||
@@ -238,15 +269,12 @@ impl RpcHandler {
|
||||
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
||||
return Err(anyhow::anyhow!("Invalid amount"));
|
||||
}
|
||||
let target_conf = params
|
||||
.get("target_conf")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(6);
|
||||
if !(1..=1008).contains(&target_conf) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
let (target_conf, custom_rate) = super::fee_policy::fee_options(¶ms)?;
|
||||
anyhow::ensure!(
|
||||
custom_rate.is_none(),
|
||||
"Fee estimation requires a confirmation target"
|
||||
);
|
||||
let target_conf = target_conf.unwrap_or(super::fee_policy::DEFAULT_TARGET);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
@@ -453,6 +481,56 @@ impl RpcHandler {
|
||||
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).
|
||||
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
@@ -491,50 +569,15 @@ impl RpcHandler {
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to list transactions")?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(false);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"Wallet transaction verification is unavailable"
|
||||
);
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse transactions response")?;
|
||||
let i64_field = |tx: &serde_json::Value, k: &str| -> i64 {
|
||||
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)
|
||||
Ok(confirmed_address_sats(&body, address)? >= min_sats)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(
|
||||
@@ -732,10 +775,24 @@ impl RpcHandler {
|
||||
total_amount += amount;
|
||||
}
|
||||
|
||||
let sat_per_vbyte = params
|
||||
.get("fee_rate_sat_per_vbyte")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
let (_, explicit_rate) = super::fee_policy::fee_options(&serde_json::json!({
|
||||
"sat_per_vbyte": params.get("fee_rate_sat_per_vbyte")
|
||||
}))?;
|
||||
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!(
|
||||
total_amount = total_amount,
|
||||
@@ -743,8 +800,6 @@ impl RpcHandler {
|
||||
"Creating PSBT for hardware wallet signing"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let fund_body = serde_json::json!({
|
||||
"raw": {
|
||||
"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)]
|
||||
mod tests {
|
||||
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
|
||||
/// any input. Built programmatically rather than pasted as opaque base64 so
|
||||
/// the fixture states what it is.
|
||||
@@ -1444,3 +1696,155 @@ mod tests {
|
||||
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 = ¶ms.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 = ¶ms.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(¶ms.intent, ¶ms.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(¶ms, 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,
|
||||
¶ms.intent,
|
||||
¶ms.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: ¶ms.intent,
|
||||
selection: ¶ms.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());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user