Compare commits
464
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28a7df45ea | ||
|
|
bdcf7015fa | ||
|
|
4cd6449b17 | ||
|
|
2d19d03e16 | ||
|
|
ffba074e3b | ||
|
|
9123121918 | ||
|
|
70b59e4341 | ||
|
|
2b173bb2e5 | ||
|
|
2428f34614 | ||
|
|
7f0318c68a | ||
|
|
733c4b1d90 | ||
|
|
a242221641 | ||
|
|
384fb7f881 | ||
|
|
f00e2c1033 | ||
|
|
c970ff3bc1 | ||
|
|
019a2fa35c | ||
|
|
ec76512ae7 | ||
|
|
0d16c53ccb | ||
|
|
d281171124 | ||
|
|
dfc4c659a6 | ||
|
|
1102566ce4 | ||
|
|
ef8c2c30dc | ||
|
|
f6e84fdcb4 | ||
|
|
a4df61000c | ||
|
|
2dc6121eea | ||
|
|
658a8922d8 | ||
|
|
23c88da4c4 | ||
|
|
71de1a3c27 | ||
|
|
bbf8dc76c4 | ||
|
|
9d5e73455a | ||
|
|
5f01efda61 | ||
|
|
54f1ec8322 | ||
|
|
fc54525092 | ||
|
|
e52c140069 | ||
|
|
1f24000226 | ||
|
|
b50fc5627c | ||
|
|
a42c24abdf | ||
|
|
5b0b35401b | ||
|
|
e1506a07b2 | ||
|
|
a5d4781ef1 | ||
|
|
8d9f4c9ff0 | ||
|
|
7becb21376 | ||
|
|
711db6b711 | ||
|
|
4b65879b6c | ||
|
|
56cdfcafcc | ||
|
|
3667105b25 | ||
|
|
bb79612772 | ||
|
|
8a7cfffd46 | ||
|
|
02671438d8 | ||
|
|
a5637d9350 | ||
|
|
19540c0578 | ||
|
|
db9ada0f31 | ||
|
|
ae84f62123 | ||
|
|
2a40cb7b23 | ||
|
|
52fb27fd43 | ||
|
|
2ed45b1094 | ||
|
|
78e8b1d44a | ||
|
|
ab6601db06 | ||
|
|
ecac80dfcb | ||
|
|
339243e861 | ||
|
|
0fb8ee7e5e | ||
|
|
4ca0772db3 | ||
|
|
83feb18063 | ||
|
|
1d6ac1a6c5 | ||
|
|
96b6ff9972 | ||
|
|
8919957d4a | ||
|
|
424070d215 | ||
|
|
16ddfa6529 | ||
|
|
85fe925763 | ||
|
|
dd909040f8 | ||
|
|
46b38f9800 | ||
|
|
783d8bfd43 | ||
|
|
5ba52f1d21 | ||
|
|
8d5410566f | ||
|
|
47f8aff3f9 | ||
|
|
cfde1343a4 | ||
|
|
daa4b55b4b | ||
|
|
846b316d72 | ||
|
|
d041f498cf | ||
|
|
e2d926f5e0 | ||
|
|
03ee3c0162 | ||
|
|
088eee55b8 | ||
|
|
48c13adaca | ||
|
|
c6f8308e9a | ||
|
|
76141ffd2f | ||
|
|
e3d2f0b827 | ||
|
|
c5cec95108 | ||
|
|
5ca345162d | ||
|
|
963dd03f83 | ||
|
|
cc35b06ce6 | ||
|
|
3ca567c04e | ||
|
|
7fde60126a | ||
|
|
f7377b0ac8 | ||
|
|
9d1970a37d | ||
|
|
2c7ba923cf | ||
|
|
f4cb5e2c74 | ||
|
|
7c23a331ee | ||
|
|
ced5342111 | ||
|
|
d4d6a655e7 | ||
|
|
70cd80c3e8 | ||
|
|
6dcaa41040 | ||
|
|
057ac1c68b | ||
|
|
1291ea1508 | ||
|
|
6b97505a59 | ||
|
|
6d19cf27c3 | ||
|
|
ca47c9fcb9 | ||
|
|
df15a9c890 | ||
|
|
9936bda9ae | ||
|
|
f9bfceb80e | ||
|
|
a6725c1b69 | ||
|
|
5d64991203 | ||
|
|
540f581927 | ||
|
|
16c84c450e | ||
|
|
cb6be9edb5 | ||
|
|
f50521072d | ||
|
|
16085c723a | ||
|
|
820df9a196 | ||
|
|
940b28dd9b | ||
|
|
5812f53c7c | ||
|
|
5e6260864e | ||
|
|
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 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 54
|
||||
versionName = "0.5.34"
|
||||
versionCode = 57
|
||||
versionName = "0.5.37"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -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()
|
||||
@@ -29,6 +29,8 @@ 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
|
||||
|
||||
@@ -71,6 +73,22 @@ internal class WebViewFullscreen(private val activity: ComponentActivity?) {
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,15 +443,23 @@ private fun injectTopInset(view: WebView) {
|
||||
return '';
|
||||
}
|
||||
function apply() {
|
||||
// IndeeHub's browse hero is an image, not a body background.
|
||||
// Padding the body puts a solid strip above the image; let the
|
||||
// hero start at the display edge while push() keeps its fixed
|
||||
// header below the status icons. Other routes keep the normal
|
||||
// content inset.
|
||||
var imageHero = location.port === '7778' &&
|
||||
(location.pathname === '/' || location.pathname === '/browse') &&
|
||||
!!document.querySelector('section img');
|
||||
var bodyBg = document.body
|
||||
? getComputedStyle(document.body).backgroundColor : '';
|
||||
var eff = effectiveTopBg();
|
||||
// Repaint body only when its declared background is not what
|
||||
// the page actually renders at the top (or paints nothing).
|
||||
var fix = eff && eff !== bodyBg
|
||||
var fix = !imageHero && eff && eff !== bodyBg
|
||||
? 'background-color:' + eff + ' !important;' : '';
|
||||
styleEl().textContent =
|
||||
'body{padding-top:' + SAT + 'px !important;' +
|
||||
'body{padding-top:' + (imageHero ? 0 : SAT) + 'px !important;' +
|
||||
'box-sizing:border-box !important;' + fix + '}';
|
||||
}
|
||||
function push(el) {
|
||||
@@ -612,6 +650,7 @@ fun WebViewScreen(
|
||||
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
|
||||
@@ -900,7 +939,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,
|
||||
@@ -915,6 +956,8 @@ 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
|
||||
@@ -1089,6 +1132,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
|
||||
@@ -1188,7 +1233,7 @@ fun WebViewScreen(
|
||||
fullscreen.show(view, callback)
|
||||
}
|
||||
|
||||
override fun onHideCustomView() = fullscreen.hide()
|
||||
override fun onHideCustomView() { cloudPip.reset(); fullscreen.hide() }
|
||||
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
loadProgress = 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 }
|
||||
}
|
||||
@@ -13,6 +13,25 @@ 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 {
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## v1.9.2-alpha (2026-10-10)
|
||||
|
||||
- Ship the Archipelago companion 0.5.37 (build 57) APK so the downloadable app matches the Android source version and its local playback diagnostics.
|
||||
- Carries all v1.9.1-alpha changes and known limitations unchanged; v1.9.1-alpha shipped the 0.5.36 companion APK and has no installer ISO.
|
||||
|
||||
## v1.9.1-alpha (2026-10-10)
|
||||
|
||||
- Add an owner terminal with xterm rendering, preserved scrollback, focused keyboard handling and Codex skills preinstalled; restrict it to the dashboard origin.
|
||||
- Prevent monitoring alerts from lingering after the condition recovers.
|
||||
- Show free disk capacity ("X free / Y total", TB-aware) from live data on Home, Monitoring and Server instead of a stale cached snapshot.
|
||||
- Allow explicit renewal of expired IndeeHub rentals, and keep the IndeeHub provider injection idempotent when the app already bundles it.
|
||||
- Make IndeeHub native login, registration and media registration reliable on managed stacks; route the IndeeHub catalog and free playback across nodes through FIPS.
|
||||
- Add a private phone setup goal, shared hardware listings, GrapheneOS Pixel artwork and Archipelago router NIP-99 listings.
|
||||
- Open the file-browser goal in the native Cloud view.
|
||||
- Keep isolated backend tests hermetic and add an ARM proposal test lane.
|
||||
|
||||
Known limitations (open acceptance gates at publication):
|
||||
|
||||
- Lightning rental on IndeeHub is not accepted: BTCPay is unconfigured on the IndeeHub API.
|
||||
- Metered ecash playback and the Shorty signed-in operator acceptance are not yet verified.
|
||||
- Remaining items in the post-1.8.22 regression checklist stay open.
|
||||
- Pre-existing historical mirror drift between ngit and Gitea is inventoried, not resolved.
|
||||
|
||||
## v1.9.0-alpha (2026-10-05)
|
||||
|
||||
Unpublished release candidate; qualification is still in progress.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Archipelago
|
||||
|
||||
> **Alpha testing:** Archipelago is experimental software. Any funds you put on it are at your own risk.
|
||||
> **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.
|
||||
|
||||
@@ -13,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
|
||||
@@ -29,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 */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -18,7 +18,8 @@ RUN set -eux; \
|
||||
mkdir -p /home/bitcoin/.bitcoin; \
|
||||
chown -R bitcoin:bitcoin /home/bitcoin
|
||||
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Guix-built,
|
||||
# x86_64-linux-gnu) extracted from the official release tarball.
|
||||
# x86_64-linux-gnu or aarch64-linux-gnu — pass the arch to the build script)
|
||||
# extracted from the official release tarball.
|
||||
COPY bin/bitcoind /usr/local/bin/bitcoind
|
||||
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
|
||||
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
|
||||
|
||||
@@ -19,7 +19,8 @@ RUN set -eux; \
|
||||
mkdir -p /home/bitcoin/.bitcoin; \
|
||||
chown -R bitcoin:bitcoin /home/bitcoin
|
||||
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Knots, Guix-built,
|
||||
# x86_64-linux-gnu) extracted from the official release tarball.
|
||||
# x86_64-linux-gnu or aarch64-linux-gnu — pass the arch to the build script)
|
||||
# extracted from the official release tarball.
|
||||
COPY bin/bitcoind /usr/local/bin/bitcoind
|
||||
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
|
||||
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
+76
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.9.0-alpha"
|
||||
version = "1.9.1-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]]
|
||||
@@ -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.9.0-alpha"
|
||||
version = "1.9.1-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"
|
||||
|
||||
@@ -31,7 +31,7 @@ use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, info};
|
||||
|
||||
const CDP_HTTP: &str = "http://127.0.0.1:9222";
|
||||
/// Marker whose presence means this node drives a local kiosk display.
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -139,10 +180,23 @@ impl ApiHandler {
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -155,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()
|
||||
@@ -235,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"))
|
||||
@@ -248,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();
|
||||
@@ -261,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,
|
||||
_ => {
|
||||
@@ -282,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
|
||||
@@ -337,7 +416,7 @@ impl ApiHandler {
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
Ok(invoice_status_response(path, |hash, id| async move {
|
||||
self.rpc_handler.settle_content_invoice(&hash, &id).await
|
||||
self.rpc_handler.content_invoice_lifecycle(&hash, &id).await
|
||||
})
|
||||
.await)
|
||||
}
|
||||
@@ -345,7 +424,11 @@ impl ApiHandler {
|
||||
/// 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"))
|
||||
@@ -357,47 +440,78 @@ 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(
|
||||
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!({
|
||||
@@ -463,9 +577,23 @@ impl ApiHandler {
|
||||
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(&self.config.data_dir, 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 });
|
||||
@@ -496,6 +624,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()
|
||||
@@ -548,7 +677,7 @@ impl ApiHandler {
|
||||
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<bool>>,
|
||||
Fut: std::future::Future<Output = Result<serde_json::Value>>,
|
||||
{
|
||||
let parsed = path
|
||||
.strip_prefix("/content/")
|
||||
@@ -567,10 +696,10 @@ where
|
||||
);
|
||||
};
|
||||
match settle(hash.to_ascii_lowercase(), id.to_owned()).await {
|
||||
Ok(paid) => build_response(
|
||||
Ok(body) => build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({"paid": paid}).to_string()),
|
||||
hyper::Body::from(body.to_string()),
|
||||
),
|
||||
Err(_) => {
|
||||
tracing::warn!("Peer-file payment status verification is temporarily unavailable");
|
||||
@@ -606,7 +735,7 @@ mod invoice_status_tests {
|
||||
let response = invoice_status_response(path, |_, _| async {
|
||||
panic!("Invalid request reached wallet");
|
||||
#[allow(unreachable_code)]
|
||||
Ok(false)
|
||||
Ok(serde_json::json!({"paid":false}))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
@@ -626,7 +755,7 @@ mod invoice_status_tests {
|
||||
let response = invoice_status_response(&path, |hash, id| async move {
|
||||
assert_eq!(hash, "ab".repeat(32));
|
||||
assert_eq!(id, "file");
|
||||
Ok(paid)
|
||||
Ok(serde_json::json!({"paid":paid}))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
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();
|
||||
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;
|
||||
@@ -17,7 +24,7 @@ use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::session::{self, SessionStore};
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
@@ -39,6 +46,43 @@ pub(super) fn build_response(
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
|
||||
}
|
||||
|
||||
fn zero_sats(value: Option<&serde_json::Value>) -> bool {
|
||||
match value {
|
||||
Some(serde_json::Value::Number(number)) => number.as_u64() == Some(0),
|
||||
Some(serde_json::Value::String(number)) => number.parse::<u64>() == Ok(0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Public playback must be explicitly free in both the mutable project row and
|
||||
/// any signed rental offer. A stale project price must never override signed
|
||||
/// paid terms, and malformed offers fail closed.
|
||||
fn indeehub_listing_is_publicly_free(listing: &serde_json::Value) -> bool {
|
||||
if !zero_sats(listing.get("rentalPrice")) {
|
||||
return false;
|
||||
}
|
||||
let Some(offer) = listing.get("offerEvent") else {
|
||||
return true;
|
||||
};
|
||||
if offer.is_null() {
|
||||
return true;
|
||||
}
|
||||
let Some(tags) = offer.get("tags").and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
let Some(terms) = tags.iter().find_map(|tag| {
|
||||
let tag = tag.as_array()?;
|
||||
(tag.first()?.as_str()? == "rental")
|
||||
.then(|| tag.get(1)?.as_str())
|
||||
.flatten()
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
serde_json::from_str::<serde_json::Value>(terms)
|
||||
.ok()
|
||||
.is_some_and(|terms| zero_sats(terms.get("priceSats")))
|
||||
}
|
||||
|
||||
pub struct ApiHandler {
|
||||
config: Config,
|
||||
rpc_handler: Arc<RpcHandler>,
|
||||
@@ -218,6 +262,339 @@ impl ApiHandler {
|
||||
))
|
||||
}
|
||||
|
||||
/// Serve IndeeHub's sanitized public project catalog on the FIPS peer
|
||||
/// listener. The app container remains on loopback; peers never receive
|
||||
/// its session-gated port directly.
|
||||
async fn handle_indeehub_public_catalog(&self) -> Result<Response<hyper::Body>> {
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?
|
||||
.get("http://127.0.0.1:7778/api/projects/public-catalog")
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await;
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "IndeeHub public catalog loopback fetch failed");
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
|
||||
));
|
||||
}
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
|
||||
));
|
||||
}
|
||||
let bytes = response.bytes().await?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() <= 8 * 1024 * 1024,
|
||||
"IndeeHub catalog exceeds limit"
|
||||
);
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).context("IndeeHub catalog is not valid JSON")?;
|
||||
anyhow::ensure!(value.is_array(), "IndeeHub catalog must be an array");
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(bytes),
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve a free IndeeHub title to its local HLS manifest. IndeeHub's
|
||||
/// optional-auth guard remains the authority: paid content returns 403.
|
||||
async fn handle_indeehub_public_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let id = path
|
||||
.strip_prefix("/api/public-indeehub-stream/")
|
||||
.unwrap_or("");
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"invalid content id"}"#),
|
||||
));
|
||||
}
|
||||
let catalog = reqwest::Client::new()
|
||||
.get("http://127.0.0.1:7778/api/projects/public-catalog")
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
if !catalog.status().is_success() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
|
||||
));
|
||||
}
|
||||
let listings: serde_json::Value = catalog.json().await?;
|
||||
let listing = listings.as_array().and_then(|items| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item.get("id").and_then(|v| v.as_str()) == Some(id))
|
||||
});
|
||||
let Some(listing) = listing else {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"film is not publicly listed"}"#),
|
||||
));
|
||||
};
|
||||
if !indeehub_listing_is_publicly_free(listing) {
|
||||
return Ok(build_response(
|
||||
StatusCode::PAYMENT_REQUIRED,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"rental required"}"#),
|
||||
));
|
||||
}
|
||||
let upstream = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:7778/api/contents/{id}/stream"))
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
let status = upstream.status();
|
||||
let mut value: serde_json::Value = upstream
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
|
||||
if status.is_success() {
|
||||
let content_id = value.get("id").and_then(|v| v.as_str()).unwrap_or(id);
|
||||
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
|
||||
if let Some(storage_path) = file.strip_prefix("/storage/") {
|
||||
value["file"] = serde_json::json!(format!(
|
||||
"/api/public-indeehub-media/{content_id}/{storage_path}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
|
||||
}
|
||||
|
||||
/// Proxy encrypted HLS manifests and segments from IndeeHub's public
|
||||
/// bucket. The manifest key URI is rebound to the guarded free-key route.
|
||||
async fn handle_indeehub_public_media(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let tail = path
|
||||
.strip_prefix("/api/public-indeehub-media/")
|
||||
.unwrap_or("");
|
||||
let Some((content_id, object)) = tail.split_once('/') else {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("invalid media path"),
|
||||
));
|
||||
};
|
||||
let safe_id = !content_id.is_empty()
|
||||
&& content_id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() || c == '-');
|
||||
let safe_object = !object.is_empty()
|
||||
&& !object.split('/').any(|part| part == "..")
|
||||
&& object
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'));
|
||||
if !safe_id || !safe_object {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("invalid media path"),
|
||||
));
|
||||
}
|
||||
let upstream = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:7778/storage/{object}"))
|
||||
.send()
|
||||
.await?;
|
||||
let status = upstream.status();
|
||||
let content_type = upstream
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_owned();
|
||||
let mut bytes = upstream.bytes().await?.to_vec();
|
||||
if object.ends_with(".m3u8") && status.is_success() {
|
||||
let manifest = String::from_utf8(bytes)?;
|
||||
bytes = manifest
|
||||
.replace(
|
||||
&format!("/api/contents/{content_id}/key"),
|
||||
&format!("/api/public-indeehub-key/{content_id}"),
|
||||
)
|
||||
.into_bytes();
|
||||
}
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", content_type)
|
||||
.header(
|
||||
"Cache-Control",
|
||||
if object.ends_with(".m3u8") {
|
||||
"no-store"
|
||||
} else {
|
||||
"public, max-age=86400"
|
||||
},
|
||||
)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(hyper::Body::from(bytes))?)
|
||||
}
|
||||
|
||||
/// Fetch the HLS key through IndeeHub's access check. With no forwarded
|
||||
/// user credentials, only genuinely free content can succeed here.
|
||||
async fn handle_indeehub_public_key(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let id = path.strip_prefix("/api/public-indeehub-key/").unwrap_or("");
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("invalid content id"),
|
||||
));
|
||||
}
|
||||
let upstream = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:7778/api/contents/{id}/key"))
|
||||
.send()
|
||||
.await?;
|
||||
let status = upstream.status();
|
||||
let bytes = upstream.bytes().await?;
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(hyper::Body::from(bytes))?)
|
||||
}
|
||||
|
||||
async fn request_indeehub_peer(&self, onion: &str, path: &str) -> Result<reqwest::Response> {
|
||||
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let node = nodes
|
||||
.iter()
|
||||
.find(|node| node.onion == onion)
|
||||
.context("IndeeHub peer is not connected")?;
|
||||
let (response, _transport) =
|
||||
crate::fips::dial::PeerRequest::new(node.fips_npub.as_deref(), &node.onion, path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(15))
|
||||
.send_get()
|
||||
.await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Same-origin browser bridge for a connected peer's free stream. The
|
||||
/// remote node still performs IndeeHub's free/paid authorization check.
|
||||
async fn handle_indeehub_peer_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let tail = path
|
||||
.strip_prefix("/api/peer-indeehub-stream/")
|
||||
.unwrap_or("");
|
||||
let Some((onion, id)) = tail.split_once('/') else {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"invalid peer stream"}"#),
|
||||
));
|
||||
};
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"invalid content id"}"#),
|
||||
));
|
||||
}
|
||||
let upstream = self
|
||||
.request_indeehub_peer(onion, &format!("/api/public-indeehub-stream/{id}"))
|
||||
.await?;
|
||||
let status = upstream.status();
|
||||
let mut value: serde_json::Value = upstream
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
|
||||
if status.is_success() {
|
||||
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
|
||||
if let Some(remote_path) = file.strip_prefix("/api/public-indeehub-media/") {
|
||||
value["file"] = serde_json::json!(format!(
|
||||
"/api/peer-indeehub-media/{onion}/{remote_path}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
|
||||
}
|
||||
|
||||
async fn handle_indeehub_peer_media(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let tail = path.strip_prefix("/api/peer-indeehub-media/").unwrap_or("");
|
||||
let Some((onion, remote_path)) = tail.split_once('/') else {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("invalid peer media"),
|
||||
));
|
||||
};
|
||||
let upstream = self
|
||||
.request_indeehub_peer(onion, &format!("/api/public-indeehub-media/{remote_path}"))
|
||||
.await?;
|
||||
let status = upstream.status();
|
||||
let content_type = upstream
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_owned();
|
||||
let mut bytes = upstream.bytes().await?.to_vec();
|
||||
if remote_path.ends_with(".m3u8") && status.is_success() {
|
||||
let manifest = String::from_utf8(bytes)?;
|
||||
bytes = manifest
|
||||
.replace(
|
||||
"/api/public-indeehub-key/",
|
||||
&format!("/api/peer-indeehub-key/{onion}/"),
|
||||
)
|
||||
.into_bytes();
|
||||
}
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", content_type)
|
||||
.header(
|
||||
"Cache-Control",
|
||||
if remote_path.ends_with(".m3u8") {
|
||||
"no-store"
|
||||
} else {
|
||||
"public, max-age=86400"
|
||||
},
|
||||
)
|
||||
.body(hyper::Body::from(bytes))?)
|
||||
}
|
||||
|
||||
async fn handle_indeehub_peer_key(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let tail = path.strip_prefix("/api/peer-indeehub-key/").unwrap_or("");
|
||||
let Some((onion, id)) = tail.split_once('/') else {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("invalid peer key"),
|
||||
));
|
||||
};
|
||||
let upstream = self
|
||||
.request_indeehub_peer(onion, &format!("/api/public-indeehub-key/{id}"))
|
||||
.await?;
|
||||
let status = upstream.status();
|
||||
let bytes = upstream.bytes().await?;
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(bytes))?)
|
||||
}
|
||||
|
||||
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
|
||||
/// browser download. The archive is passphrase-encrypted at rest; the
|
||||
/// session gate at the route controls who can fetch it.
|
||||
@@ -311,6 +688,40 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// A node app on another port shares host-only owner cookies. Never grant
|
||||
// that app access to the owner shell, even though other app APIs accept
|
||||
// same-host cross-port origins. Browser terminal requests must originate
|
||||
// from the exact dashboard authority (Host includes its port).
|
||||
fn has_terminal_origin(headers: &hyper::HeaderMap) -> bool {
|
||||
let Some(origin) = headers
|
||||
.get(hyper::header::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = headers
|
||||
.get(hyper::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
origin == format!("http://{host}") || origin == format!("https://{host}")
|
||||
}
|
||||
|
||||
// Browsers do not send Origin on a same-origin GET. Fetch Metadata is a
|
||||
// browser-controlled header and distinguishes the dashboard from an app on
|
||||
// another port, which is only same-site. Mutating requests and WebSockets
|
||||
// still require the exact Origin above.
|
||||
fn has_terminal_read_origin(headers: &hyper::HeaderMap) -> bool {
|
||||
if headers.contains_key(hyper::header::ORIGIN) {
|
||||
return Self::has_terminal_origin(headers);
|
||||
}
|
||||
headers
|
||||
.get("sec-fetch-site")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
== Some("same-origin")
|
||||
}
|
||||
|
||||
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
|
||||
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
|
||||
/// proxied under other ports (APP_PORTS) call this from within the same
|
||||
@@ -384,6 +795,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 +831,23 @@ 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());
|
||||
}
|
||||
if !Self::has_terminal_origin(req.headers()) {
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
|
||||
));
|
||||
}
|
||||
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 +878,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();
|
||||
@@ -457,7 +917,25 @@ impl ApiHandler {
|
||||
|
||||
match (method, path.as_str()) {
|
||||
// RPC — auth is handled inside rpc handler per-method
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
|
||||
(Method::POST, "/rpc/v1") => {
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
let mut response = self.rpc_handler.clone().handle(req_with_bytes).await?;
|
||||
if !origin.is_empty() {
|
||||
response.headers_mut().insert(
|
||||
"access-control-allow-origin",
|
||||
hyper::header::HeaderValue::from_str(&origin)?,
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"access-control-allow-credentials",
|
||||
hyper::header::HeaderValue::from_static("true"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
hyper::header::VARY,
|
||||
hyper::header::HeaderValue::from_static("Origin"),
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
// AIUI model proxy — session-gated forwarder to Claude/Ollama,
|
||||
// replacing the unauthenticated claude-api-proxy.py sidecar and
|
||||
@@ -506,6 +984,33 @@ impl ApiHandler {
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
(Method::GET, "/api/terminal/sessions") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
if !Self::has_terminal_read_origin(&headers) {
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
|
||||
));
|
||||
}
|
||||
terminal::list_response().await
|
||||
}
|
||||
(Method::POST, "/api/terminal/sessions") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
if !Self::has_terminal_origin(&headers) {
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
|
||||
));
|
||||
}
|
||||
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 +1089,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 +1105,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 +1116,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 +1126,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 +1137,67 @@ 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))?)
|
||||
}
|
||||
|
||||
// Public IndeeHub metadata is also exposed on the FIPS peer
|
||||
// listener, whose path filter is the only ingress gate here.
|
||||
(Method::GET, "/api/public-catalog/indeedhub") => {
|
||||
self.handle_indeehub_public_catalog().await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/api/public-indeehub-stream/") => {
|
||||
self.handle_indeehub_public_stream(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/api/public-indeehub-media/") => {
|
||||
self.handle_indeehub_public_media(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/api/public-indeehub-key/") => {
|
||||
self.handle_indeehub_public_key(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/api/peer-indeehub-stream/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_indeehub_peer_stream(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/api/peer-indeehub-media/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_indeehub_peer_media(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/api/peer-indeehub-key/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_indeehub_peer_key(p).await
|
||||
}
|
||||
|
||||
(Method::GET, "/api/app-catalog") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
@@ -784,3 +1359,89 @@ fn sanitize_html(s: &str) -> String {
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod terminal_origin_tests {
|
||||
use super::ApiHandler;
|
||||
use hyper::header::{HeaderMap, HeaderValue, HOST, ORIGIN};
|
||||
|
||||
fn allowed(host: &str, origin: Option<&str>) -> bool {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
|
||||
if let Some(origin) = origin {
|
||||
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||
}
|
||||
ApiHandler::has_terminal_origin(&headers)
|
||||
}
|
||||
|
||||
fn read_allowed(host: &str, origin: Option<&str>, fetch_site: Option<&str>) -> bool {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
|
||||
if let Some(origin) = origin {
|
||||
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||
}
|
||||
if let Some(fetch_site) = fetch_site {
|
||||
headers.insert("sec-fetch-site", HeaderValue::from_str(fetch_site).unwrap());
|
||||
}
|
||||
ApiHandler::has_terminal_read_origin(&headers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_accepts_only_the_exact_dashboard_authority() {
|
||||
assert!(allowed("node.local", Some("https://node.local")));
|
||||
assert!(allowed("192.168.1.2:7778", Some("http://192.168.1.2:7778")));
|
||||
assert!(!allowed("node.local", Some("https://node.local:7778")));
|
||||
assert!(!allowed("node.local:7778", Some("https://node.local")));
|
||||
assert!(!allowed("node.local", Some("https://evil.example")));
|
||||
assert!(!allowed("node.local", Some("null")));
|
||||
assert!(!allowed("node.local", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_read_accepts_same_origin_fetch_but_not_same_site_apps() {
|
||||
assert!(read_allowed("node.local", None, Some("same-origin")));
|
||||
assert!(!read_allowed("node.local", None, Some("same-site")));
|
||||
assert!(!read_allowed("node.local", None, Some("cross-site")));
|
||||
assert!(!read_allowed("node.local", None, None));
|
||||
assert!(!read_allowed(
|
||||
"node.local",
|
||||
Some("https://evil.example"),
|
||||
Some("same-site")
|
||||
));
|
||||
assert!(!read_allowed(
|
||||
"node.local",
|
||||
Some("https://evil.example"),
|
||||
Some("same-origin")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod indeehub_public_stream_tests {
|
||||
use super::indeehub_listing_is_publicly_free;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn public_stream_requires_explicitly_free_project_and_signed_terms() {
|
||||
assert!(indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 0,
|
||||
"offerEvent": null
|
||||
})));
|
||||
assert!(indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": "0",
|
||||
"offerEvent": {"tags": [["rental", "{\"priceSats\":0}"]]}
|
||||
})));
|
||||
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 15,
|
||||
"offerEvent": null
|
||||
})));
|
||||
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 0,
|
||||
"offerEvent": {"tags": [["rental", "{\"priceSats\":15}"]]}
|
||||
})));
|
||||
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 0,
|
||||
"offerEvent": {"tags": [["rental", "invalid"]]}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,290 @@
|
||||
//! 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 = match Command::new("tmux")
|
||||
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Terminal runtime is not installed on this node"}"#),
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,12 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Unit tests deliberately construct a handler without an orchestrator.
|
||||
// Never let that mock cross into a real Podman command on the host.
|
||||
if cfg!(test) && self.orchestrator.is_none() {
|
||||
return Ok(serde_json::json!([]));
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "json"])
|
||||
.output()
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -76,7 +108,7 @@ fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
|
||||
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)
|
||||
existing_paid_content(dir.path(), "seller.onion", "id", None, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
@@ -95,7 +127,7 @@ async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavail
|
||||
.await
|
||||
.unwrap();
|
||||
for (id, filename) in [("id", None), ("duplicate-id", Some("/file.txt"))] {
|
||||
let cached = existing_paid_content(dir.path(), "seller.onion", id, filename)
|
||||
let cached = existing_paid_content(dir.path(), "seller.onion", id, filename, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
@@ -104,7 +136,7 @@ async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavail
|
||||
assert_eq!(cached["data"], "cGFpZA==");
|
||||
}
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "different.onion", "id", None)
|
||||
existing_paid_content(dir.path(), "different.onion", "id", None, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
@@ -113,7 +145,7 @@ async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavail
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "id", None)
|
||||
existing_paid_content(dir.path(), "seller.onion", "id", None, false)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
@@ -123,7 +155,7 @@ async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavail
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "other-id", None)
|
||||
existing_paid_content(dir.path(), "seller.onion", "other-id", None, false)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
@@ -136,3 +168,268 @@ async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavail
|
||||
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,
|
||||
@@ -159,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,
|
||||
@@ -322,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,
|
||||
@@ -330,6 +354,44 @@ 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.browse-indeehub-peers" => self.handle_content_browse_indeehub_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
|
||||
|
||||
@@ -112,6 +112,42 @@ 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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,63 @@ fn fee_budget(
|
||||
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,
|
||||
@@ -501,7 +558,22 @@ impl RpcHandler {
|
||||
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?;
|
||||
@@ -797,6 +869,20 @@ mod tests {
|
||||
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);
|
||||
|
||||
@@ -1,8 +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
|
||||
@@ -565,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| {
|
||||
@@ -518,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(
|
||||
@@ -1328,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.
|
||||
@@ -1561,3 +1774,77 @@ mod peer_file_invoice_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_secs() >= params.intent.created_at.saturating_sub(30)
|
||||
&& event.created_at.as_secs() <= 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_secs();
|
||||
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")
|
||||
.is_some_and(|entry| matches!(
|
||||
entry.state,
|
||||
crate::data_model::PackageState::Running
|
||||
)),
|
||||
"The installed IndeeHub app 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());
|
||||
}
|
||||
}
|
||||
@@ -317,14 +317,14 @@ mod sanitize_tests {
|
||||
/// Deterministic: same session token always produces the same CSRF token.
|
||||
/// Survives backend restarts because it depends only on the session token
|
||||
/// and the on-disk remember secret (not ephemeral state).
|
||||
pub(crate) async fn derive_csrf_token(session_token: &str) -> String {
|
||||
pub(crate) async fn derive_csrf_token(session_token: &str) -> std::io::Result<String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
let secret = SessionStore::load_or_create_remember_secret().await;
|
||||
let secret = SessionStore::load_or_create_remember_secret().await?;
|
||||
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
|
||||
mac.update(format!("csrf:{}", session_token).as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// Extract a named cookie value from headers.
|
||||
|
||||
@@ -17,8 +17,13 @@ mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
mod lightning_purchase;
|
||||
pub(crate) mod lnd;
|
||||
mod marketplace;
|
||||
mod media_registration;
|
||||
mod onchain_purchase;
|
||||
mod playback;
|
||||
mod purchase;
|
||||
// pub(crate): 13-10's `assistant::backends::select_backend` reuses
|
||||
// `mesh::assistant::detect_ollama()` (D-04) rather than re-probing —
|
||||
// matches the existing `pub(crate) mod bitcoin_relay;`/`pub(crate) mod
|
||||
@@ -34,6 +39,7 @@ mod nostr;
|
||||
mod onboarding_gate;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
mod publishing;
|
||||
pub(crate) use package::patch_indeedhub_nostr_provider;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
mod peers;
|
||||
@@ -97,6 +103,39 @@ fn nostr_signing_origin_allowed(headers: &hyper::HeaderMap, dev_mode: bool) -> b
|
||||
matches!(url.port_or_known_default(), Some(80 | 443))
|
||||
}
|
||||
|
||||
fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_mode: bool) -> bool {
|
||||
!matches!(
|
||||
method,
|
||||
"node.nostr-sign"
|
||||
| "identity.nostr-sign"
|
||||
| "media.registration.prepare"
|
||||
| "media.registration.context"
|
||||
| "media.registration.resolve"
|
||||
| "content.rental-purchase"
|
||||
| "content.onchain-cancel"
|
||||
| "content.onchain-attempt"
|
||||
| "content.onchain-create"
|
||||
| "content.onchain-expose"
|
||||
| "content.onchain-prepare"
|
||||
| "content.onchain-pay"
|
||||
| "content.onchain-recover"
|
||||
| "content.onchain-download"
|
||||
| "content.invoice-pay"
|
||||
| "content.invoice-download"
|
||||
| "content.invoice-attempt"
|
||||
| "content.invoice-retry-native"
|
||||
| "content.invoice-create"
|
||||
| "content.invoice-recover"
|
||||
| "content.invoice-cancel"
|
||||
| "content.purchase"
|
||||
| "content.cancel-purchase"
|
||||
| "content.playback-handle"
|
||||
| "content.playback-status"
|
||||
| "content.playback-prepare"
|
||||
| "content.playback-start"
|
||||
) || nostr_signing_origin_allowed(headers, dev_mode)
|
||||
}
|
||||
|
||||
/// Read-only authenticated methods may skip CSRF, but they must still exist in
|
||||
/// the dispatcher. The tab signer uses `system.get-hostname` as its lightweight
|
||||
/// session probe, so keeping the policy in one testable function protects that
|
||||
@@ -148,6 +187,7 @@ pub struct RpcHandler {
|
||||
pub(crate) app_gate: Arc<crate::appgate::AppGate>,
|
||||
endpoint_rate_limiter: EndpointRateLimiter,
|
||||
response_cache: ResponseCache,
|
||||
playback_handles: crate::playback_handles::PlaybackHandles,
|
||||
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
||||
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
|
||||
/// job at a time, since flashing needs exclusive access to the port.
|
||||
@@ -172,6 +212,10 @@ pub struct RpcHandler {
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(crate) fn playback_handles(&self) -> &crate::playback_handles::PlaybackHandles {
|
||||
&self.playback_handles
|
||||
}
|
||||
|
||||
pub async fn new(
|
||||
config: Config,
|
||||
state_manager: Arc<StateManager>,
|
||||
@@ -231,6 +275,7 @@ impl RpcHandler {
|
||||
app_gate,
|
||||
endpoint_rate_limiter,
|
||||
response_cache: ResponseCache::new(5),
|
||||
playback_handles: Default::default(),
|
||||
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
flash_job: crate::mesh::flash::new_job_handle(),
|
||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
@@ -333,20 +378,26 @@ impl RpcHandler {
|
||||
|
||||
debug!("RPC method: {}", rpc_req.method);
|
||||
|
||||
if matches!(
|
||||
rpc_req.method.as_str(),
|
||||
"node.nostr-sign" | "identity.nostr-sign"
|
||||
) && !nostr_signing_origin_allowed(&parts.headers, self.config.dev_mode)
|
||||
{
|
||||
if !native_consent_origin_allowed(&rpc_req.method, &parts.headers, self.config.dev_mode) {
|
||||
return Ok(self.error_response(
|
||||
403,
|
||||
"Nostr signing from app origins requires the dashboard consent bridge",
|
||||
"Native signing and Cloud registration from app origins require the dashboard consent bridge",
|
||||
StatusCode::FORBIDDEN,
|
||||
));
|
||||
}
|
||||
|
||||
// Enforce authentication for non-allowlisted methods
|
||||
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
|
||||
if !is_unauthenticated || rpc_req.method.starts_with("auth.") {
|
||||
if let Err(error) = SessionStore::load_or_create_remember_secret().await {
|
||||
tracing::error!(%error, "Persistent session signing key unavailable");
|
||||
return Ok(self.error_response(
|
||||
503,
|
||||
"Sign-in temporarily unavailable. Check server session storage.",
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut new_session_cookies: Option<(String, String)> = None;
|
||||
if !is_unauthenticated {
|
||||
let mut authenticated = match &session_token {
|
||||
@@ -359,7 +410,7 @@ impl RpcHandler {
|
||||
if let Some(remember) = extract_cookie(&parts.headers, "remember") {
|
||||
if crate::session::SessionStore::validate_remember_token(&remember).await {
|
||||
let new_token = self.session_store.create().await;
|
||||
let new_csrf = derive_csrf_token(&new_token).await;
|
||||
let new_csrf = derive_csrf_token(&new_token).await?;
|
||||
tracing::info!("Auto-restored session from remember-me token");
|
||||
new_session_cookies = Some((new_token, new_csrf));
|
||||
authenticated = true;
|
||||
@@ -407,7 +458,7 @@ impl RpcHandler {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
let secret = SessionStore::load_or_create_remember_secret().await;
|
||||
let secret = SessionStore::load_or_create_remember_secret().await?;
|
||||
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
@@ -424,29 +475,28 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
if !csrf_valid {
|
||||
// Debug: log expected vs received for diagnosis
|
||||
if let (Some(token), Some(header)) = (&session_token, &csrf_header) {
|
||||
let expected = derive_csrf_token(token).await;
|
||||
tracing::warn!(
|
||||
method = %rpc_req.method,
|
||||
session_prefix = %&token[..8.min(token.len())],
|
||||
csrf_prefix = %&header[..8.min(header.len())],
|
||||
expected_prefix = %&expected[..8.min(expected.len())],
|
||||
"403 CSRF mismatch — session/csrf/expected prefixes shown"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
method = %rpc_req.method,
|
||||
has_session = session_token.is_some(),
|
||||
has_header = csrf_header.is_some(),
|
||||
"403 CSRF validation failed — rejecting RPC call"
|
||||
);
|
||||
}
|
||||
return Ok(self.error_response(
|
||||
tracing::warn!(method = %rpc_req.method, "CSRF mismatch; rejecting action and refreshing authenticated session token");
|
||||
let mut response = self.error_response(
|
||||
403,
|
||||
"CSRF token missing or invalid",
|
||||
StatusCode::FORBIDDEN,
|
||||
));
|
||||
);
|
||||
// Authentication and RBAC have already passed. Reject this
|
||||
// request without dispatch, but refresh the deterministic CSRF
|
||||
// cookie so the browser can retry normally after a key rotation
|
||||
// or a stale companion cookie. Never return a token to an
|
||||
// unauthenticated client or relax CSRF validation on retry.
|
||||
if let Some(token) = &session_token {
|
||||
self.set_csrf_cookie(
|
||||
&mut response,
|
||||
&derive_csrf_token(token).await?,
|
||||
secure_suffix,
|
||||
);
|
||||
}
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Cache-Control", cookie_header("private, no-store"));
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +601,7 @@ impl RpcHandler {
|
||||
client_ip,
|
||||
secure_suffix,
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -603,7 +653,7 @@ impl RpcHandler {
|
||||
new_session_cookies: &Option<(String, String)>,
|
||||
client_ip: std::net::IpAddr,
|
||||
secure_suffix: &str,
|
||||
) {
|
||||
) -> Result<()> {
|
||||
// Track failed login attempts for rate limiting
|
||||
if method == "auth.login" && rpc_resp.error.is_some() {
|
||||
self.login_rate_limiter.record_failure(client_ip).await;
|
||||
@@ -643,7 +693,7 @@ impl RpcHandler {
|
||||
if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await {
|
||||
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
|
||||
let token = self.session_store.create_pending(secret).await;
|
||||
let csrf_token = derive_csrf_token(&token).await;
|
||||
let csrf_token = derive_csrf_token(&token).await?;
|
||||
self.set_session_cookie(response, &token, secure_suffix);
|
||||
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||
let totp_body = serde_json::json!({
|
||||
@@ -656,8 +706,8 @@ impl RpcHandler {
|
||||
}
|
||||
} else {
|
||||
let token = self.session_store.create().await;
|
||||
let csrf_token = derive_csrf_token(&token).await;
|
||||
let remember_token = self.session_store.create_remember_token().await;
|
||||
let csrf_token = derive_csrf_token(&token).await?;
|
||||
let remember_token = self.session_store.create_remember_token().await?;
|
||||
self.set_session_cookie(response, &token, secure_suffix);
|
||||
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||
self.set_remember_cookie(response, &remember_token, secure_suffix);
|
||||
@@ -676,8 +726,8 @@ impl RpcHandler {
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(new_token) = new_token_opt {
|
||||
let csrf_token = derive_csrf_token(&new_token).await;
|
||||
let remember_token = self.session_store.create_remember_token().await;
|
||||
let csrf_token = derive_csrf_token(&new_token).await?;
|
||||
let remember_token = self.session_store.create_remember_token().await?;
|
||||
self.set_session_cookie(response, &new_token, secure_suffix);
|
||||
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||
self.set_remember_cookie(response, &remember_token, secure_suffix);
|
||||
@@ -696,7 +746,7 @@ impl RpcHandler {
|
||||
if method == "auth.changePassword" && rpc_resp.error.is_none() {
|
||||
if let Some(token) = session_token {
|
||||
let new_token = self.session_store.rotate(token).await;
|
||||
let csrf_token = derive_csrf_token(&new_token).await;
|
||||
let csrf_token = derive_csrf_token(&new_token).await?;
|
||||
self.set_session_cookie(response, &new_token, secure_suffix);
|
||||
self.set_csrf_cookie(response, &csrf_token, secure_suffix);
|
||||
}
|
||||
@@ -728,6 +778,7 @@ impl RpcHandler {
|
||||
self.set_session_cookie(response, new_session, secure_suffix);
|
||||
self.set_csrf_cookie(response, new_csrf, secure_suffix);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_session_cookie(
|
||||
@@ -789,6 +840,43 @@ mod nostr_signing_origin_tests {
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_registration_and_purchase_use_dashboard_origin_and_keep_authentication_and_csrf() {
|
||||
for method in [
|
||||
"media.registration.prepare",
|
||||
"media.registration.context",
|
||||
"media.registration.resolve",
|
||||
"content.rental-purchase",
|
||||
"content.onchain-cancel",
|
||||
"content.onchain-attempt",
|
||||
"content.onchain-create",
|
||||
"content.onchain-expose",
|
||||
"content.onchain-prepare",
|
||||
"content.onchain-pay",
|
||||
"content.onchain-recover",
|
||||
"content.onchain-download",
|
||||
"content.purchase",
|
||||
"content.cancel-purchase",
|
||||
"content.playback-handle",
|
||||
"content.playback-status",
|
||||
"content.playback-prepare",
|
||||
"content.playback-start",
|
||||
] {
|
||||
assert!(!native_consent_origin_allowed(
|
||||
method,
|
||||
&headers(Some("http://node.local:7778")),
|
||||
false
|
||||
));
|
||||
assert!(native_consent_origin_allowed(
|
||||
method,
|
||||
&headers(Some("https://node.local")),
|
||||
false
|
||||
));
|
||||
assert!(!UNAUTHENTICATED_METHODS.contains(&method));
|
||||
assert!(!csrf_exempt_method(method));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_accepts_dashboard_and_authenticated_non_browser_clients() {
|
||||
assert!(nostr_signing_origin_allowed(&headers(None), false));
|
||||
@@ -834,3 +922,92 @@ mod session_probe_contract_tests {
|
||||
assert!(!DISPATCHER.contains("\"system.get-version\" =>"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod csrf_recovery_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_csrf_is_refreshed_without_executing_action_or_authenticating_strangers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = dir.path().to_path_buf();
|
||||
config.dev_mode = false;
|
||||
let sessions =
|
||||
crate::session::SessionStore::new_for_tests(dir.path().join("sessions.json"));
|
||||
let token = sessions.create().await;
|
||||
let handler = Arc::new(
|
||||
RpcHandler::new(
|
||||
config,
|
||||
Arc::new(crate::state::StateManager::new()),
|
||||
Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
sessions,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let request = |session: &str, csrf: Option<&str>, secure: bool| {
|
||||
let mut builder = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/rpc/v1")
|
||||
.header("Cookie", format!("session={session}"));
|
||||
if let Some(csrf) = csrf {
|
||||
builder = builder.header("X-CSRF-Token", csrf);
|
||||
}
|
||||
if secure {
|
||||
builder = builder.header("X-Forwarded-Proto", "https");
|
||||
}
|
||||
builder.body(hyper::Body::from(serde_json::json!({
|
||||
"jsonrpc":"2.0", "id":1, "method":"system.settings.set",
|
||||
"params":{"key":"ai_provider", "value":"{\"provider\":\"local\",\"openai_model\":\"\"}"}
|
||||
}).to_string())).unwrap()
|
||||
};
|
||||
let settings = dir.path().join("settings/model-provider.json");
|
||||
for stale in [None, Some("stale-token"), Some("00")] {
|
||||
let response = handler
|
||||
.clone()
|
||||
.handle(request(&token, stale, true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert!(!settings.exists(), "rejected action must not have run");
|
||||
let cookies: Vec<_> = response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.map(|v| v.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(cookies.len(), 1);
|
||||
let expected = derive_csrf_token(&token).await.unwrap();
|
||||
assert_eq!(
|
||||
cookies[0],
|
||||
format!("csrf_token={expected}; SameSite=Lax; Path=/; Secure")
|
||||
);
|
||||
assert_eq!(response.headers()["cache-control"], "private, no-store");
|
||||
}
|
||||
let stranger = handler
|
||||
.clone()
|
||||
.handle(request("not-a-session", None, true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stranger.status(), StatusCode::UNAUTHORIZED);
|
||||
assert!(!stranger.headers().contains_key("set-cookie"));
|
||||
assert!(!settings.exists());
|
||||
let valid = derive_csrf_token(&token).await.unwrap();
|
||||
let response = handler
|
||||
.handle(request(&token, Some(&valid), false))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&hyper::body::to_bytes(response.into_body()).await.unwrap())
|
||||
.unwrap();
|
||||
assert!(
|
||||
body["error"].is_null(),
|
||||
"valid retry must reach the handler"
|
||||
);
|
||||
assert!(settings.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
//! Owner-only original-operation on-chain flow. No generic sendcoins fallback.
|
||||
use super::RpcHandler;
|
||||
use crate::{
|
||||
content_lightning::Binding,
|
||||
content_onchain::{self as engine, Journal, Phase, Record},
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Params {
|
||||
onion: String,
|
||||
content_id: String,
|
||||
operation_id: Option<String>,
|
||||
price_sats: Option<u64>,
|
||||
max_fee_sats: Option<u64>,
|
||||
sat_per_vbyte: Option<u64>,
|
||||
template_sha256: Option<String>,
|
||||
plan_sha256: Option<String>,
|
||||
}
|
||||
fn public(record: &Record) -> Result<Value> {
|
||||
let fee = record
|
||||
.template
|
||||
.as_ref()
|
||||
.map(|t| engine::validate_funded(record, t))
|
||||
.transpose()?;
|
||||
let template_sha256 = record
|
||||
.template
|
||||
.as_ref()
|
||||
.map(|t| hex::encode(Sha256::digest(t.psbt_base64.as_bytes())));
|
||||
Ok(
|
||||
json!({"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"phase":record.phase,
|
||||
"network":record.network(),"external_exposure":record.externally_exposed,
|
||||
"address":if record.externally_exposed {record.quote.as_ref().map(|q|q.address.as_str())}else{None},
|
||||
"fee_sats":fee.or_else(|| record.plan.as_ref().map(|p|p.fee_sats)),
|
||||
"max_fee_sats":record.policy.as_ref().map(|p|p.max_fee_sats).or_else(||record.plan.as_ref().map(|p|p.max_fee_sats)),
|
||||
"template_sha256":template_sha256,"plan_sha256":record.plan.as_ref().map(|p|p.hash()).transpose()?,
|
||||
"txid":record.signed.as_ref().map(|s|s.txid.as_str()),"paid":record.settled,
|
||||
"change_allocation_ambiguous":matches!(record.change_address,Some(engine::ChangeAddress::Dispatched)),
|
||||
"can_switch_method":record.retirement.is_some(),"retired_unallocated":record.retirement.is_some()}),
|
||||
)
|
||||
}
|
||||
impl RpcHandler {
|
||||
async fn request_onchain_allocation(
|
||||
&self,
|
||||
record: &Record,
|
||||
fips: &str,
|
||||
) -> Result<crate::content_onchain_seller::Record> {
|
||||
let operation = crate::api::handler::onchain_purchase::Operation {
|
||||
binding: record.binding.clone(),
|
||||
action: "allocate".into(),
|
||||
};
|
||||
let (mut response, _) = crate::fips::dial::PeerRequest::new(
|
||||
Some(fips),
|
||||
&record.seller_onion,
|
||||
crate::api::handler::onchain_purchase::ROUTE,
|
||||
)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(45))
|
||||
.send_content_json(
|
||||
&self.config.data_dir,
|
||||
&record.binding.seller_did,
|
||||
&operation,
|
||||
)
|
||||
.await
|
||||
.context("Original seller allocation reply unavailable; recover the same operation")?;
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Original seller allocation remains unresolved"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"Seller response too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let saved: crate::content_onchain_seller::Record = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
saved.binding == record.binding,
|
||||
"Seller changed original purchase"
|
||||
);
|
||||
if let Some(offer) = &record.offer {
|
||||
anyhow::ensure!(saved.offer()? == *offer, "Seller changed original offer");
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
pub(super) async fn ensure_onchain_allows_other_rail(
|
||||
&self,
|
||||
buyer: &str,
|
||||
seller: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
Journal::find_for(&self.config.data_dir, buyer, seller, content)?.is_none(),
|
||||
"An original on-chain purchase remains recoverable; do not pay again or switch methods"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub(super) async fn handle_onchain_operation(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
action: &str,
|
||||
) -> Result<Value> {
|
||||
let params: Params = serde_json::from_value(params.context("Missing on-chain operation")?)?;
|
||||
anyhow::ensure!(
|
||||
!params.content_id.starts_with("registered_"),
|
||||
"Registered rentals require their native purchase contract"
|
||||
);
|
||||
let peer =
|
||||
crate::federation::load_unique_payment_peer(&self.config.data_dir, ¶ms.onion)
|
||||
.await?;
|
||||
let buyer =
|
||||
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
|
||||
.await?
|
||||
.did_key()?;
|
||||
anyhow::ensure!(buyer != peer.did, "Cannot buy from this same node");
|
||||
let _admission = crate::content_payment_admission::lock(
|
||||
&self.config.data_dir,
|
||||
&buyer,
|
||||
&peer.did,
|
||||
¶ms.content_id,
|
||||
)
|
||||
.await?;
|
||||
let original = if let Some(id) = ¶ms.operation_id {
|
||||
let journal = Journal::open(&self.config.data_dir, id).await?;
|
||||
let original = journal.load()?;
|
||||
if let Some(record) = &original {
|
||||
anyhow::ensure!(
|
||||
record.binding.buyer_did == buyer
|
||||
&& record.binding.seller_did == peer.did
|
||||
&& record.binding.content_id == params.content_id,
|
||||
"Original on-chain operation belongs to another purchase"
|
||||
);
|
||||
}
|
||||
original
|
||||
} else {
|
||||
Journal::find_for(&self.config.data_dir, &buyer, &peer.did, ¶ms.content_id)?
|
||||
};
|
||||
if action == "lookup" {
|
||||
return Ok(json!({"attempt":original.as_ref().map(public).transpose()?}));
|
||||
}
|
||||
if let Some(id) = ¶ms.operation_id {
|
||||
anyhow::ensure!(
|
||||
original.as_ref().is_some_and(|r| &r.binding.id == id),
|
||||
"Original on-chain operation changed"
|
||||
);
|
||||
}
|
||||
let mut record = if let Some(record) = original {
|
||||
record
|
||||
} else {
|
||||
anyhow::ensure!(
|
||||
matches!(action, "create" | "expose") && params.operation_id.is_none(),
|
||||
"Recover original on-chain operation first"
|
||||
);
|
||||
self.ensure_invoice_allows_other_rail(&buyer, &peer.did, ¶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 original Cashu purchase first"
|
||||
);
|
||||
Record::new(
|
||||
Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: buyer,
|
||||
seller_did: peer.did.clone(),
|
||||
content_id: params.content_id.clone(),
|
||||
price_sats: params.price_sats.context("Expected price required")?,
|
||||
},
|
||||
params.onion.clone(),
|
||||
)?
|
||||
};
|
||||
anyhow::ensure!(
|
||||
record.seller_onion == params.onion
|
||||
&& params
|
||||
.price_sats
|
||||
.is_none_or(|p| p == record.binding.price_sats),
|
||||
"Original payment address or price changed"
|
||||
);
|
||||
let journal = Journal::open(&self.config.data_dir, &record.binding.id).await?;
|
||||
if journal.load()?.is_none() {
|
||||
journal.save(&record)?;
|
||||
}
|
||||
if record.retirement.is_some() {
|
||||
return public(&record);
|
||||
}
|
||||
if action == "cancel" {
|
||||
anyhow::ensure!(params.operation_id.is_some() && record.can_retire_unallocated(),"An allocated or mutated on-chain purchase cannot be canceled; recover its original payment");
|
||||
}
|
||||
if matches!(action, "create" | "expose" | "prepare" | "pay") && !record.settled {
|
||||
// Recheck while the same admission guard is held, including resumes
|
||||
// from another window and records predating this owner flow.
|
||||
self.ensure_invoice_allows_other_rail(
|
||||
&record.binding.buyer_did,
|
||||
&peer.did,
|
||||
¶ms.content_id,
|
||||
)
|
||||
.await?;
|
||||
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
cashu
|
||||
.find_buyers(&record.binding.buyer_did, &peer.did, ¶ms.content_id)
|
||||
.await?
|
||||
.iter()
|
||||
.all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
|
||||
"Another saved Cashu liability must be recovered before on-chain dispatch"
|
||||
);
|
||||
}
|
||||
if action == "prepare" {
|
||||
anyhow::ensure!(
|
||||
params.operation_id.is_some(),
|
||||
"Original operation ID required"
|
||||
);
|
||||
if record.template.is_none() && record.plan.is_none() {
|
||||
let max_fee_sats = params
|
||||
.max_fee_sats
|
||||
.context("Explicit maximum fee required")?;
|
||||
anyhow::ensure!(
|
||||
(1..=2_100_000_000_000_000).contains(&max_fee_sats),
|
||||
"Invalid maximum fee"
|
||||
);
|
||||
if let Some(rate) = params.sat_per_vbyte {
|
||||
anyhow::ensure!((1..=5000).contains(&rate), "Invalid fee rate");
|
||||
}
|
||||
let wallet = self.onchain_purchase_wallet().await?;
|
||||
let change = wallet.prepare_change(&journal).await?;
|
||||
record = wallet
|
||||
.prepare_plan(
|
||||
&journal,
|
||||
super::lnd::onchain_purchase::PlanRequest {
|
||||
change_address: change,
|
||||
max_fee_sats,
|
||||
sat_per_vbyte: params.sat_per_vbyte,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
return public(&record);
|
||||
}
|
||||
if action == "pay" {
|
||||
anyhow::ensure!(
|
||||
params.operation_id.is_some(),
|
||||
"Original operation ID required"
|
||||
);
|
||||
if record.settled {
|
||||
return public(&record);
|
||||
}
|
||||
if let Some(plan) = &record.plan {
|
||||
anyhow::ensure!(
|
||||
params.plan_sha256.as_deref() == Some(plan.hash()?.as_str()),
|
||||
"Confirm the original saved funding plan before payment"
|
||||
);
|
||||
} else {
|
||||
let template = record
|
||||
.template
|
||||
.as_ref()
|
||||
.context("Review the original fee first")?;
|
||||
anyhow::ensure!(
|
||||
params.template_sha256.as_deref()
|
||||
== Some(
|
||||
hex::encode(Sha256::digest(template.psbt_base64.as_bytes())).as_str()
|
||||
),
|
||||
"Confirm the original saved transaction before payment"
|
||||
);
|
||||
}
|
||||
let wallet = self.onchain_purchase_wallet().await?;
|
||||
if record.plan.is_some() && record.quote.is_none() {
|
||||
record = engine::lease_plan(&journal, &wallet).await?;
|
||||
engine::mark_address_allocation(&journal, false)?;
|
||||
let status = self
|
||||
.request_onchain_allocation(
|
||||
&record,
|
||||
peer.fips_npub
|
||||
.as_deref()
|
||||
.context("Seller has no authenticated mesh connection")?,
|
||||
)
|
||||
.await?;
|
||||
let quote = status
|
||||
.quote()?
|
||||
.context("Original seller allocation is unresolved; recover this operation")?;
|
||||
record = engine::accept_quote(&journal, quote)?;
|
||||
}
|
||||
if record.plan.is_some() && record.template.is_none() {
|
||||
record = engine::bind_plan(&journal)?;
|
||||
}
|
||||
if matches!(
|
||||
record.phase,
|
||||
Phase::TemplatePrepared | Phase::LeaseDispatched
|
||||
) {
|
||||
record = engine::drive(&journal, &wallet, engine::Action::Lease, None).await?;
|
||||
}
|
||||
if matches!(record.phase, Phase::Funded | Phase::SigningDispatched) {
|
||||
record = engine::drive(&journal, &wallet, engine::Action::Sign, None).await?;
|
||||
}
|
||||
if matches!(
|
||||
record.phase,
|
||||
Phase::Signed | Phase::BroadcastDispatched | Phase::Published
|
||||
) {
|
||||
record = engine::drive(&journal, &wallet, engine::Action::Publish, None).await?;
|
||||
}
|
||||
return public(&record);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
action,
|
||||
"create" | "status" | "expose" | "download" | "cancel"
|
||||
),
|
||||
"Unsupported on-chain action"
|
||||
);
|
||||
let fips = peer
|
||||
.fips_npub
|
||||
.context("Seller has no authenticated mesh connection")?;
|
||||
if action == "expose" && record.offer.is_some() && record.quote.is_none() {
|
||||
engine::mark_address_allocation(&journal, true)?;
|
||||
let status = self.request_onchain_allocation(&record, &fips).await?;
|
||||
record = engine::accept_quote(
|
||||
&journal,
|
||||
status
|
||||
.quote()?
|
||||
.context("Original seller allocation is unresolved; recover this operation")?,
|
||||
)?;
|
||||
}
|
||||
let remote_action = if action == "create" || action == "expose" && record.offer.is_none() {
|
||||
"offer"
|
||||
} else if action == "expose" {
|
||||
"status"
|
||||
} else {
|
||||
action
|
||||
};
|
||||
let operation = crate::api::handler::onchain_purchase::Operation {
|
||||
binding: record.binding.clone(),
|
||||
action: remote_action.into(),
|
||||
};
|
||||
let route = crate::api::handler::onchain_purchase::ROUTE;
|
||||
let remote = crate::fips::dial::PeerRequest::new(Some(&fips), ¶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 remote {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(
|
||||
json!({"attempt":public(&record)?,"recovery_required":true,"error":"Original on-chain request is saved. Recover this operation; do not request another address or pay again."}),
|
||||
)
|
||||
}
|
||||
};
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Seller could not recover original on-chain purchase {}; retain it",
|
||||
record.binding.id
|
||||
);
|
||||
if action == "download" {
|
||||
let source = record
|
||||
.quote
|
||||
.as_ref()
|
||||
.context("Recover original address first")?
|
||||
.source
|
||||
.clone();
|
||||
anyhow::ensure!(
|
||||
response.content_length() == Some(source.size),
|
||||
"Original file length changed"
|
||||
);
|
||||
record.settled = true;
|
||||
journal.save(&record)?;
|
||||
let stream = crate::content_purchase_download::verified_stream(
|
||||
response.bytes_stream(),
|
||||
source.sha256,
|
||||
source.size,
|
||||
);
|
||||
let owned = crate::content_owned::record_purchase_stream(
|
||||
&self.config.data_dir,
|
||||
crate::content_owned::OwnedItem {
|
||||
onion: params.onion,
|
||||
content_id: params.content_id,
|
||||
filename: source.filename,
|
||||
mime_type: source.mime_type,
|
||||
size_bytes: source.size,
|
||||
paid_sats: record.binding.price_sats,
|
||||
ecash_backend: "onchain".into(),
|
||||
purchased_at: chrono::Utc::now().to_rfc3339(),
|
||||
download_complete: false,
|
||||
},
|
||||
Box::pin(stream),
|
||||
Some(source.size),
|
||||
)
|
||||
.await?;
|
||||
return Ok(
|
||||
json!({"owned":true,"owned_content_id":owned.content_id,"mime_type":owned.mime_type}),
|
||||
);
|
||||
}
|
||||
let mut bytes = vec![];
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"On-chain response too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let body: Value = serde_json::from_slice(&bytes)?;
|
||||
if body["state"] == "cancelled_unallocated" {
|
||||
let ack: crate::content_onchain_seller::UnallocatedAck = serde_json::from_value(body)?;
|
||||
record = engine::retire_unallocated(&journal, ack)?;
|
||||
return public(&record);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
action != "cancel",
|
||||
"Seller did not acknowledge unallocated retirement; preserve original operation"
|
||||
);
|
||||
let status: crate::content_onchain_seller::Record = serde_json::from_value(body)?;
|
||||
anyhow::ensure!(
|
||||
status.binding == record.binding,
|
||||
"Seller changed original purchase"
|
||||
);
|
||||
if record.offer.is_none() && record.quote.is_none() {
|
||||
record = engine::accept_offer(&journal, status.offer()?)?;
|
||||
}
|
||||
if let Some(quote) = status.quote()? {
|
||||
record = engine::accept_quote(&journal, quote)?;
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!status.paid || record.quote.is_some(),
|
||||
"Paid purchase lacks original address"
|
||||
);
|
||||
record.settled |= status.paid;
|
||||
journal.save(&record)?;
|
||||
if action == "expose" && !record.settled {
|
||||
if record.quote.is_none() {
|
||||
engine::mark_address_allocation(&journal, true)?;
|
||||
let status = self.request_onchain_allocation(&record, &fips).await?;
|
||||
engine::accept_quote(
|
||||
&journal,
|
||||
status.quote()?.context(
|
||||
"Original seller allocation is unresolved; recover this operation",
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
engine::expose_address(&journal)?;
|
||||
record = journal.load()?.context("Original record unavailable")?;
|
||||
}
|
||||
public(&record)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn binding() -> Binding {
|
||||
Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap(),
|
||||
seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([8; 32])).unwrap(),
|
||||
content_id: "file".into(),
|
||||
price_sats: 546,
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn rental_preserves_unresolved_lightning_operation_on_review_and_delayed_consent() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let identity = crate::identity::NodeIdentity::load_or_create(&data.path().join("identity"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut binding = binding();
|
||||
binding.buyer_did = identity.did_key().unwrap();
|
||||
binding.content_id = "registered_rental".into();
|
||||
let onion = format!("{}.onion", "a".repeat(56));
|
||||
let peer = serde_json::from_value(json!({
|
||||
"did": binding.seller_did, "pubkey": hex::encode([8; 32]),
|
||||
"onion": onion, "trust_level": "trusted", "added_at": "now",
|
||||
"fips_npub": "fixture-no-network"
|
||||
}))
|
||||
.unwrap();
|
||||
crate::federation::save_nodes(data.path(), &[peer])
|
||||
.await
|
||||
.unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = data.path().to_path_buf();
|
||||
let handler = RpcHandler::new(
|
||||
config,
|
||||
std::sync::Arc::new(crate::state::StateManager::new()),
|
||||
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let journal = crate::content_lightning::Journal::open(data.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let record = crate::content_lightning::BuyerRecord {
|
||||
binding: crate::content_lightning::Binding {
|
||||
id: binding.id.clone(),
|
||||
buyer_did: binding.buyer_did.clone(),
|
||||
seller_did: binding.seller_did.clone(),
|
||||
content_id: binding.content_id.clone(),
|
||||
price_sats: 546,
|
||||
},
|
||||
seller_onion: onion,
|
||||
external_exposure: true,
|
||||
native_retired: false,
|
||||
native_replacement: None,
|
||||
native_dispatched: false,
|
||||
native_result: None,
|
||||
last: None,
|
||||
};
|
||||
journal.save_buyer(&record).unwrap();
|
||||
drop(journal);
|
||||
for consent in [
|
||||
None,
|
||||
Some(json!({
|
||||
"operation_id": uuid::Uuid::new_v4().to_string(),
|
||||
"envelope_sha256": "cd".repeat(32), "wallet_debit_sats": 546
|
||||
})),
|
||||
] {
|
||||
let error = handler
|
||||
.handle_content_rental_purchase(Some(json!({
|
||||
"seller_did": binding.seller_did, "content_id": binding.content_id,
|
||||
"expected_sha256": "ab".repeat(32), "expected_price_sats": 546,
|
||||
"expected_viewing_seconds": 3600, "max_wallet_debit": 546, "consent": consent
|
||||
})))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("externally payable invoice remains unresolved"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
let journal = crate::content_lightning::Journal::open(data.path())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(journal.buyer(&binding.id).unwrap().unwrap()).unwrap(),
|
||||
serde_json::to_value(record).unwrap()
|
||||
);
|
||||
assert!(!data.path().join("wallet").exists());
|
||||
assert!(crate::content_purchase::Journal::open(data.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.find_buyers(&binding.buyer_did, &binding.seller_did, &binding.content_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rental_waits_for_alternate_rail_commit_and_rejects_quote_and_consent_recovery() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let identity = crate::identity::NodeIdentity::load_or_create(&data.path().join("identity"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut binding = binding();
|
||||
binding.buyer_did = identity.did_key().unwrap();
|
||||
binding.content_id = "registered_rental".into();
|
||||
let onion = format!("{}.onion", "a".repeat(56));
|
||||
let peer = serde_json::from_value(json!({
|
||||
"did": binding.seller_did, "pubkey": hex::encode([8; 32]),
|
||||
"onion": onion, "trust_level": "trusted", "added_at": "now",
|
||||
"fips_npub": "fixture-no-network"
|
||||
}))
|
||||
.unwrap();
|
||||
crate::federation::save_nodes(data.path(), &[peer])
|
||||
.await
|
||||
.unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = data.path().to_path_buf();
|
||||
let handler = RpcHandler::new(
|
||||
config,
|
||||
std::sync::Arc::new(crate::state::StateManager::new()),
|
||||
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// The other rail owns admission before it persists the uncertain spend.
|
||||
let admission = crate::content_payment_admission::lock(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let params = json!({
|
||||
"seller_did": binding.seller_did, "content_id": binding.content_id,
|
||||
"expected_sha256": "ab".repeat(32), "expected_price_sats": 546,
|
||||
"expected_viewing_seconds": 3600, "max_wallet_debit": 546
|
||||
});
|
||||
let pending = handler.handle_content_rental_purchase(Some(params.clone()));
|
||||
tokio::pin!(pending);
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(50), &mut pending)
|
||||
.await
|
||||
.is_err(),
|
||||
"Rental must wait for cross-rail admission before inspecting journals/context"
|
||||
);
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
journal
|
||||
.save(&Record::new(binding.clone(), onion.clone()).unwrap())
|
||||
.unwrap();
|
||||
drop(journal);
|
||||
let path = data
|
||||
.path()
|
||||
.join("content-onchain")
|
||||
.join(format!("{}.json", binding.id));
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
drop(admission);
|
||||
let error = tokio::time::timeout(std::time::Duration::from_secs(5), &mut pending)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("original on-chain purchase"),
|
||||
"{error:#}"
|
||||
);
|
||||
for consent in [
|
||||
None,
|
||||
Some(json!({
|
||||
"operation_id": uuid::Uuid::new_v4().to_string(),
|
||||
"envelope_sha256": "cd".repeat(32), "wallet_debit_sats": 546
|
||||
})),
|
||||
] {
|
||||
let mut retry = params.clone();
|
||||
retry["consent"] = consent.unwrap_or(serde_json::Value::Null);
|
||||
let error = handler
|
||||
.handle_content_rental_purchase(Some(retry))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("original on-chain purchase"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
assert!(!data.path().join("wallet").exists());
|
||||
assert!(crate::content_purchase::Journal::open(data.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.find_buyers(&binding.buyer_did, &binding.seller_did, &binding.content_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unresolved_onchain_operation_blocks_cashu_and_every_lightning_spend_entry() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let identity = crate::identity::NodeIdentity::load_or_create(&data.path().join("identity"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut binding = binding();
|
||||
binding.buyer_did = identity.did_key().unwrap();
|
||||
let onion = format!("{}.onion", "a".repeat(56));
|
||||
let peer = serde_json::from_value(json!({
|
||||
"did": binding.seller_did, "pubkey": hex::encode([8; 32]),
|
||||
"onion": onion, "trust_level": "trusted", "added_at": "now",
|
||||
"fips_npub": "fixture-no-network"
|
||||
}))
|
||||
.unwrap();
|
||||
crate::federation::save_nodes(data.path(), &[peer])
|
||||
.await
|
||||
.unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = data.path().to_path_buf();
|
||||
let handler = RpcHandler::new(
|
||||
config,
|
||||
std::sync::Arc::new(crate::state::StateManager::new()),
|
||||
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
journal
|
||||
.save(&Record::new(binding.clone(), onion.clone()).unwrap())
|
||||
.unwrap();
|
||||
drop(journal);
|
||||
let original = std::fs::read(
|
||||
data.path()
|
||||
.join("content-onchain")
|
||||
.join(format!("{}.json", binding.id)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Exercise the real RPC entry points, not just the admission helper.
|
||||
// Each must reject before a Cashu/Lightning journal, offer, invoice or
|
||||
// wallet dispatch is created, including delayed callbacks and retries.
|
||||
for consent in [
|
||||
None,
|
||||
Some(json!({
|
||||
"operation_id": uuid::Uuid::new_v4().to_string(),
|
||||
"envelope_sha256": "ab".repeat(32), "wallet_debit_sats": 546
|
||||
})),
|
||||
] {
|
||||
let error = handler
|
||||
.handle_content_purchase(Some(json!({
|
||||
"onion": onion, "content_id": binding.content_id,
|
||||
"max_wallet_debit": 546, "consent": consent
|
||||
})))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("original on-chain purchase"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
for (action, exposure) in [
|
||||
("create", false),
|
||||
("pay", false),
|
||||
("retry", false),
|
||||
("create", true),
|
||||
("status", true),
|
||||
] {
|
||||
let error = handler
|
||||
.handle_lightning_operation(
|
||||
Some(json!({
|
||||
"onion": onion, "content_id": binding.content_id,
|
||||
"price_sats": 546, "external_exposure": exposure
|
||||
})),
|
||||
action,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("original on-chain purchase"),
|
||||
"{action}: {error:#}"
|
||||
);
|
||||
}
|
||||
// Read-only recovery lookup remains available despite the blocked rail.
|
||||
let lookup = handler
|
||||
.handle_lightning_operation(
|
||||
Some(json!({
|
||||
"onion": onion, "content_id": binding.content_id
|
||||
})),
|
||||
"lookup",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(lookup["attempt"].is_null());
|
||||
assert_eq!(
|
||||
std::fs::read(
|
||||
data.path()
|
||||
.join("content-onchain")
|
||||
.join(format!("{}.json", binding.id))
|
||||
)
|
||||
.unwrap(),
|
||||
original
|
||||
);
|
||||
assert!(!data.path().join("wallet").exists());
|
||||
// No replacement operation has been persisted by any rejected call.
|
||||
assert!(crate::content_purchase::Journal::open(data.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.find_buyers(&binding.buyer_did, &binding.seller_did, &binding.content_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(crate::content_lightning::Journal::open(data.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.buyer_for(&binding.buyer_did, &binding.seller_did, &binding.content_id)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buyer_discovery_retains_unresolved_address_and_rejects_duplicate_operations() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let saved = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap();
|
||||
let j = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
j.save(&saved).unwrap();
|
||||
drop(j);
|
||||
let found = Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(found.binding.id, binding.id);
|
||||
assert!(found.blocks_other_rails());
|
||||
assert!(public(&found).unwrap()["address"].is_null());
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.seller_did,
|
||||
&binding.buyer_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut second = binding.clone();
|
||||
second.id = uuid::Uuid::new_v4().to_string();
|
||||
let j = Journal::open(data.path(), &second.id).await.unwrap();
|
||||
j.save(&Record::new(second, saved.seller_onion).unwrap())
|
||||
.unwrap();
|
||||
drop(j);
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupted_node_record_cannot_be_treated_as_permission_to_pay_again() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let j = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
j.save(&Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap())
|
||||
.unwrap();
|
||||
drop(j);
|
||||
std::fs::write(
|
||||
data.path()
|
||||
.join("content-onchain")
|
||||
.join(format!("{}.json", binding.id)),
|
||||
b"{}",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn only_durable_matching_empty_ack_releases_cross_rail_and_stale_callback_cannot_revive()
|
||||
{
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let _rail = crate::content_payment_admission::lock(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
let original = Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap();
|
||||
journal.save(&original).unwrap();
|
||||
let ack = crate::content_onchain_seller::UnallocatedAck {
|
||||
binding: binding.clone(),
|
||||
state: "cancelled_unallocated".into(),
|
||||
address: serde_json::Value::Null,
|
||||
allocation_dispatched: false,
|
||||
can_switch_method: true,
|
||||
};
|
||||
for wrong in [
|
||||
crate::content_onchain_seller::UnallocatedAck {
|
||||
allocation_dispatched: true,
|
||||
..ack.clone()
|
||||
},
|
||||
crate::content_onchain_seller::UnallocatedAck {
|
||||
address: serde_json::json!("not-empty"),
|
||||
..ack.clone()
|
||||
},
|
||||
crate::content_onchain_seller::UnallocatedAck {
|
||||
binding: Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
..binding.clone()
|
||||
},
|
||||
..ack.clone()
|
||||
},
|
||||
] {
|
||||
assert!(engine::retire_unallocated(&journal, wrong).is_err());
|
||||
}
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
let retired = engine::retire_unallocated(&journal, ack).unwrap();
|
||||
assert!(!retired.blocks_other_rails());
|
||||
assert!(public(&retired).unwrap()["can_switch_method"] == true);
|
||||
assert!(journal.save(&original).is_err());
|
||||
drop(journal);
|
||||
assert!(Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let mut replacement = binding.clone();
|
||||
replacement.id = uuid::Uuid::new_v4().to_string();
|
||||
let journal = Journal::open(data.path(), &replacement.id).await.unwrap();
|
||||
journal
|
||||
.save(&Record::new(replacement.clone(), original.seller_onion).unwrap())
|
||||
.unwrap();
|
||||
drop(journal);
|
||||
assert_eq!(
|
||||
Journal::find_for(
|
||||
data.path(),
|
||||
&binding.buyer_did,
|
||||
&binding.seller_did,
|
||||
&binding.content_id
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.binding
|
||||
.id,
|
||||
replacement.id
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn owner_address_is_redacted_until_exposure_is_durable_and_cannot_then_be_retired() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let binding = binding();
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
journal
|
||||
.save(&Record::new(binding.clone(), format!("{}.onion", "a".repeat(56))).unwrap())
|
||||
.unwrap();
|
||||
let mut bytes = vec![0, 20];
|
||||
bytes.extend([1; 20]);
|
||||
let address = bitcoin::Address::from_script(
|
||||
&bitcoin::ScriptBuf::from_bytes(bytes),
|
||||
bitcoin::Network::Regtest,
|
||||
)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let saved = engine::accept_quote(
|
||||
&journal,
|
||||
engine::Quote {
|
||||
binding: binding.clone(),
|
||||
address: address.clone(),
|
||||
network: engine::ChainNetwork::Regtest,
|
||||
source: crate::content_lightning::RetainedFile {
|
||||
sha256: "a".repeat(64),
|
||||
size: 4,
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(public(&saved).unwrap()["address"].is_null());
|
||||
let ack = crate::content_onchain_seller::UnallocatedAck {
|
||||
binding: binding.clone(),
|
||||
state: "cancelled_unallocated".into(),
|
||||
address: serde_json::Value::Null,
|
||||
allocation_dispatched: false,
|
||||
can_switch_method: true,
|
||||
};
|
||||
assert!(engine::retire_unallocated(&journal, ack.clone()).is_err());
|
||||
assert_eq!(engine::expose_address(&journal).unwrap(), address);
|
||||
drop(journal);
|
||||
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
|
||||
let exposed = journal.load().unwrap().unwrap();
|
||||
assert!(exposed.externally_exposed);
|
||||
assert_eq!(public(&exposed).unwrap()["address"], address);
|
||||
assert!(engine::retire_unallocated(&journal, ack).is_err());
|
||||
assert!(exposed.blocks_other_rails());
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,11 @@ impl RpcHandler {
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
if result.get("status").and_then(|v| v.as_str()) == Some("up-to-date") {
|
||||
if result.get("status").and_then(|v| v.as_str()) == Some("staged") {
|
||||
PackageState::Stopped
|
||||
} else if result.get("status").and_then(|v| v.as_str())
|
||||
== Some("up-to-date")
|
||||
{
|
||||
pre_state.clone().unwrap_or(PackageState::Running)
|
||||
} else {
|
||||
PackageState::Running
|
||||
@@ -373,12 +377,34 @@ impl RpcHandler {
|
||||
Err(e) => {
|
||||
error!("package.update {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("UPDATE FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Inner handler already ran rollback_update + cleared
|
||||
// update state, but be defensive: revert to pre-state
|
||||
// in case the inner flow died before its cleanup.
|
||||
if let Some(prev) = pre_state {
|
||||
set_package_state(&handler.state_manager, &package_id_spawn, prev).await;
|
||||
}
|
||||
// Release the transitional overlay before asking the scanner
|
||||
// for real state. Prior Running is not proof of successful
|
||||
// rollback, and a failed preflight is not proof of Stopped.
|
||||
handler
|
||||
.state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
finish_failed_update(entry);
|
||||
}
|
||||
data.notifications.retain(|item| {
|
||||
item.id != format!("update-failed-{package_id_spawn}")
|
||||
});
|
||||
data.notifications.push(crate::data_model::Notification {
|
||||
id: format!("update-failed-{package_id_spawn}"),
|
||||
level: crate::data_model::NotificationLevel::Error,
|
||||
title: format!("Could not update {package_id_spawn}"),
|
||||
message: format!(
|
||||
"{e}. Runtime recovery does not roll back database changes."
|
||||
),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
app_id: Some(package_id_spawn.clone()),
|
||||
});
|
||||
while data.notifications.len() > 20 {
|
||||
data.notifications.remove(0);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
kick_scanner_and_wait(&handler).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -398,53 +424,12 @@ impl RpcHandler {
|
||||
/// Matches what the inner handler's `set_install_progress` would do on first
|
||||
/// call, but fires before the spawn so the UI sees it immediately.
|
||||
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
||||
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Leave icon empty during the transient Installing window:
|
||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||
// the scanner refreshes the entry. The frontend's `icon`
|
||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||
// has the correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
});
|
||||
.or_insert_with(|| super::progress::create_installing_entry(package_id));
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
})
|
||||
@@ -622,3 +607,104 @@ async fn kick_scanner_and_wait(handler: &RpcHandler) {
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
fn finish_failed_update(entry: &mut crate::data_model::PackageDataEntry) {
|
||||
if entry.state == PackageState::Updating {
|
||||
entry.state = PackageState::Installed;
|
||||
}
|
||||
entry.install_progress = None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod update_completion_tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn update_image_byte_progress_preserves_owner_and_failure_releases_retry_gate() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = directory.path().to_path_buf();
|
||||
let state = Arc::new(StateManager::new());
|
||||
let handler = RpcHandler::new(
|
||||
config,
|
||||
Arc::clone(&state),
|
||||
Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
crate::session::SessionStore::new_for_tests(directory.path().join("sessions.json")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.mutate_data(|data| {
|
||||
let mut entry = super::super::progress::create_installing_entry("movie");
|
||||
entry.state = PackageState::Running;
|
||||
entry.ui_ready = Some(true);
|
||||
data.package_data.insert("movie".into(), entry);
|
||||
})
|
||||
.await;
|
||||
flip_package_state(&state, "movie", PackageState::Updating).await;
|
||||
let update_ready = state.get_snapshot().await.0.package_data["movie"].ui_ready;
|
||||
assert_eq!(update_ready, Some(false));
|
||||
// Both calls happen even when an immutable image already exists locally.
|
||||
for (downloaded, total) in [(0, 0), (100, 100)] {
|
||||
handler
|
||||
.set_install_progress("movie", downloaded, total)
|
||||
.await;
|
||||
let (snapshot, _) = state.get_snapshot().await;
|
||||
let entry = &snapshot.package_data["movie"];
|
||||
assert_eq!(entry.state, PackageState::Updating);
|
||||
assert_eq!(entry.ui_ready, update_ready);
|
||||
assert_eq!(
|
||||
entry.install_progress.as_ref().unwrap().downloaded,
|
||||
downloaded
|
||||
);
|
||||
}
|
||||
RpcHandler::update_install_progress(&state, "movie", 50, 100).await;
|
||||
handler
|
||||
.set_install_phase("movie", crate::data_model::InstallPhase::Preparing)
|
||||
.await;
|
||||
handler
|
||||
.set_install_message("movie", "Checking original services")
|
||||
.await;
|
||||
assert_eq!(
|
||||
state.get_snapshot().await.0.package_data["movie"].state,
|
||||
PackageState::Updating
|
||||
);
|
||||
state
|
||||
.mutate_data(|data| finish_failed_update(data.package_data.get_mut("movie").unwrap()))
|
||||
.await;
|
||||
let (snapshot, _) = state.get_snapshot().await;
|
||||
let entry = &snapshot.package_data["movie"];
|
||||
assert_eq!(
|
||||
entry.state,
|
||||
PackageState::Installed,
|
||||
"Failed update must release RPC transition ownership for scanner/retry admission"
|
||||
);
|
||||
assert!(entry.install_progress.is_none());
|
||||
handler.set_install_progress("fresh-install", 1, 10).await;
|
||||
let (snapshot, _) = state.get_snapshot().await;
|
||||
let entry = &snapshot.package_data["fresh-install"];
|
||||
assert_eq!(entry.state, PackageState::Installing);
|
||||
assert_eq!(entry.ui_ready, Some(false));
|
||||
assert_eq!(entry.install_progress.as_ref().unwrap().downloaded, 1);
|
||||
}
|
||||
#[test]
|
||||
fn failure_releases_spinner_without_inventing_stopped_or_restored_runtime() {
|
||||
let mut entry = super::super::progress::create_installing_entry("movie");
|
||||
entry.state = PackageState::Updating;
|
||||
finish_failed_update(&mut entry);
|
||||
assert_eq!(entry.state, PackageState::Installed);
|
||||
assert!(entry.install_progress.is_none());
|
||||
for actual in [
|
||||
PackageState::Running,
|
||||
PackageState::Stopped,
|
||||
PackageState::Exited,
|
||||
] {
|
||||
entry.state = actual.clone();
|
||||
finish_failed_update(&mut entry);
|
||||
assert_eq!(
|
||||
entry.state, actual,
|
||||
"Fresh scanner evidence must win over old pre-update intent"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,7 +526,18 @@ pub(in crate::api::rpc) async fn get_containers_for_app(package_id: &str) -> Res
|
||||
.await
|
||||
.context("podman ps timed out while listing containers")?
|
||||
.context("Failed to list containers")?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
containers_from_list_output(package_id, &output)
|
||||
}
|
||||
|
||||
fn containers_from_list_output(
|
||||
package_id: &str,
|
||||
output: &std::process::Output,
|
||||
) -> Result<Vec<String>> {
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"podman ps failed while listing containers"
|
||||
);
|
||||
let stdout = std::str::from_utf8(&output.stdout).context("Invalid container list response")?;
|
||||
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
|
||||
|
||||
let patterns = all_container_names(package_id);
|
||||
@@ -543,6 +554,32 @@ pub(in crate::api::rpc) async fn get_containers_for_app(package_id: &str) -> Res
|
||||
mod tests {
|
||||
use super::{all_container_names, get_data_dirs_for_app, get_health_check_args};
|
||||
|
||||
#[test]
|
||||
fn failed_container_listing_is_not_an_absent_app() {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
let mut output = std::process::Output {
|
||||
status: std::process::ExitStatus::from_raw(1 << 8),
|
||||
stdout: vec![],
|
||||
stderr: b"store unavailable".to_vec(),
|
||||
};
|
||||
assert!(super::containers_from_list_output("node-demo-music", &output).is_err());
|
||||
output.stdout = b"node-demo-music\n".to_vec();
|
||||
assert!(super::containers_from_list_output("node-demo-music", &output).is_err());
|
||||
output.status = std::process::ExitStatus::from_raw(0);
|
||||
assert_eq!(
|
||||
super::containers_from_list_output("node-demo-music", &output).unwrap(),
|
||||
vec!["node-demo-music"]
|
||||
);
|
||||
output.stdout.clear();
|
||||
assert!(
|
||||
super::containers_from_list_output("node-demo-music", &output)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
output.stdout = vec![0xff];
|
||||
assert!(super::containers_from_list_output("node-demo-music", &output).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitcoin_variant_container_names_are_precise() {
|
||||
let core = all_container_names("bitcoin-core");
|
||||
|
||||
@@ -74,10 +74,15 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
fn patched_indeedhub_nginx_config(original: &str) -> String {
|
||||
fn patched_indeedhub_nginx_config(original: &str, inject_provider: bool) -> String {
|
||||
let mut conf = original
|
||||
.lines()
|
||||
.filter(|line| !line.contains("X-Frame-Options"))
|
||||
.filter(|line| {
|
||||
!line.contains("X-Frame-Options")
|
||||
&& (inject_provider
|
||||
|| (!line.contains("sub_filter_once")
|
||||
&& !line.contains("sub_filter '</head>'")))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
conf.push('\n');
|
||||
@@ -91,21 +96,22 @@ fn patched_indeedhub_nginx_config(original: &str) -> String {
|
||||
location = /sw.js {",
|
||||
);
|
||||
}
|
||||
if conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||
if inject_provider && conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||
conf = conf.replacen(
|
||||
"try_files $uri $uri/ /index.html;",
|
||||
"try_files $uri $uri/ /index.html;\n\
|
||||
sub_filter_once on;\n\
|
||||
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v4\"></script></head>';",
|
||||
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v5\"></script></head>';",
|
||||
1,
|
||||
);
|
||||
}
|
||||
conf = conf.replace(
|
||||
"src=\"/nostr-provider.js\"",
|
||||
"src=\"/nostr-provider.js?v=tab-signer-v4\"",
|
||||
"src=\"/nostr-provider.js?v=tab-signer-v5\"",
|
||||
);
|
||||
conf = conf.replace("tab-signer-v2", "tab-signer-v4");
|
||||
conf = conf.replace("tab-signer-v3", "tab-signer-v4");
|
||||
conf = conf.replace("tab-signer-v2", "tab-signer-v5");
|
||||
conf = conf.replace("tab-signer-v3", "tab-signer-v5");
|
||||
conf = conf.replace("tab-signer-v4", "tab-signer-v5");
|
||||
conf.replace(
|
||||
"proxy_set_header X-Forwarded-Prefix /api;",
|
||||
"proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;",
|
||||
@@ -165,6 +171,11 @@ pub(crate) async fn patch_indeedhub_nostr_provider() {
|
||||
|
||||
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
||||
let provider_dest = format!("{container_root}/usr/share/nginx/html/nostr-provider.js");
|
||||
let provider_is_bundled =
|
||||
tokio::fs::read_to_string(format!("{container_root}/usr/share/nginx/html/index.html"))
|
||||
.await
|
||||
.map(|html| html.contains("src=\"/nostr-provider.js"))
|
||||
.unwrap_or(false);
|
||||
let provider_copied = tokio::fs::metadata(provider_src).await.is_ok()
|
||||
&& tokio::process::Command::new("podman")
|
||||
.args([
|
||||
@@ -189,7 +200,7 @@ pub(crate) async fn patch_indeedhub_nostr_provider() {
|
||||
if let Ok(out) = copy_out {
|
||||
if out.status.success() {
|
||||
if let Ok(original) = tokio::fs::read_to_string(&tmp_path).await {
|
||||
let conf = patched_indeedhub_nginx_config(&original);
|
||||
let conf = patched_indeedhub_nginx_config(&original, !provider_is_bundled);
|
||||
if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() {
|
||||
config_copied = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
@@ -219,7 +230,8 @@ pub(crate) async fn patch_indeedhub_nostr_provider() {
|
||||
}
|
||||
} else if conf == original
|
||||
&& conf.contains("location = /nostr-provider.js {")
|
||||
&& conf.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"")
|
||||
&& (provider_is_bundled
|
||||
|| conf.contains("src=\"/nostr-provider.js?v=tab-signer-v5\""))
|
||||
{
|
||||
config_copied = true;
|
||||
}
|
||||
@@ -280,6 +292,10 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
let lifecycle_guard =
|
||||
crate::container::update_transaction::Guard::acquire(&self.config.data_dir)?;
|
||||
lifecycle_guard.require_clear()?;
|
||||
lifecycle_guard.require_unheld(&super::config::all_container_names(package_id))?;
|
||||
|
||||
let docker_image = params
|
||||
.get("dockerImage")
|
||||
@@ -1892,6 +1908,29 @@ autopilot.active=false\n",
|
||||
}));
|
||||
}
|
||||
|
||||
if app_id == "datum" {
|
||||
// This is the same platform-owned secret injected into DATUM and
|
||||
// Gashboard. Never publish it in the manifest or a UI fallback.
|
||||
let password = tokio::fs::read_to_string(
|
||||
self.config.data_dir.join("secrets/datum-admin-password"),
|
||||
)
|
||||
.await
|
||||
.context("DATUM credentials are not available yet; wait for installation to finish")?;
|
||||
let password = password.trim();
|
||||
anyhow::ensure!(
|
||||
!password.is_empty(),
|
||||
"DATUM administrator password is empty"
|
||||
);
|
||||
return Ok(serde_json::json!({
|
||||
"title": "DATUM Gateway login",
|
||||
"description": "Use this password when DATUM asks you to unlock configuration. In Config, set your Bitcoin payout address. Point miners at this node's IP address on Stratum port 23334 (stratum+tcp://NODE-IP:23334). Gashboard connects automatically.",
|
||||
"credentials": [
|
||||
{ "label": "Username", "value": "admin" },
|
||||
{ "label": "Password", "value": password, "sensitive": true }
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
if app_id == "photoprism" {
|
||||
return Ok(serde_json::json!({
|
||||
"title": "PhotoPrism credentials",
|
||||
@@ -2591,19 +2630,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let patched = patched_indeedhub_nginx_config(original);
|
||||
let patched = patched_indeedhub_nginx_config(original, true);
|
||||
assert!(!patched.contains("X-Frame-Options"));
|
||||
assert!(patched.contains("location = /nostr-provider.js {"));
|
||||
assert!(patched.contains("Cache-Control \"no-cache, no-store, must-revalidate\""));
|
||||
assert!(patched.contains("src=\"/nostr-provider.js?v=tab-signer-v4\""));
|
||||
assert!(patched.contains("src=\"/nostr-provider.js?v=tab-signer-v5\""));
|
||||
assert!(patched.contains("X-Forwarded-Prefix $http_x_forwarded_prefix/api"));
|
||||
assert_eq!(patched_indeedhub_nginx_config(&patched), patched);
|
||||
assert_eq!(patched_indeedhub_nginx_config(&patched, true), patched);
|
||||
|
||||
let previous_broker = patched.replace("tab-signer-v4", "tab-signer-v3");
|
||||
let migrated = patched_indeedhub_nginx_config(&previous_broker);
|
||||
assert!(migrated.contains("tab-signer-v4"));
|
||||
let previous_broker = patched.replace("tab-signer-v5", "tab-signer-v3");
|
||||
let migrated = patched_indeedhub_nginx_config(&previous_broker, true);
|
||||
assert!(migrated.contains("tab-signer-v5"));
|
||||
assert!(!migrated.contains("tab-signer-v3"));
|
||||
assert_eq!(patched_indeedhub_nginx_config(&migrated), migrated);
|
||||
assert_eq!(patched_indeedhub_nginx_config(&migrated, true), migrated);
|
||||
|
||||
let bundled = patched_indeedhub_nginx_config(&patched, false);
|
||||
assert!(!bundled.contains("sub_filter"));
|
||||
assert_eq!(patched_indeedhub_nginx_config(&bundled, false), bundled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,8 +20,13 @@ impl RpcHandler {
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
// Image pulls also report byte progress during updates, including
|
||||
// the local immutable-image shortcut. Preserve the RPC owner so
|
||||
// update failure cleanup can release its transitional state.
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
@@ -145,9 +150,9 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a minimal PackageDataEntry for a package being installed.
|
||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
/// Seed an Installing entry from its manifest before any container exists.
|
||||
pub(super) fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
let mut entry = PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
@@ -155,10 +160,8 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Empty icon: hardcoding `<id>.png` is wrong for apps that use
|
||||
// `.svg` or `.webp` assets and produces a broken-image flicker.
|
||||
// The frontend's `icon` computed falls through to the curated
|
||||
// map which has correct extensions for known apps.
|
||||
// Filled below from the manifest, without guessing the extension.
|
||||
// Unmanifested apps can still use the frontend catalog fallback.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
@@ -185,7 +188,9 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
}
|
||||
};
|
||||
crate::container::docker_packages::apply_manifest_presentation(package_id, &mut entry);
|
||||
entry
|
||||
}
|
||||
|
||||
/// Parse podman pull progress output.
|
||||
|
||||
@@ -60,6 +60,10 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
let lifecycle_guard =
|
||||
crate::container::update_transaction::Guard::acquire(&self.config.data_dir)?;
|
||||
lifecycle_guard.require_clear()?;
|
||||
lifecycle_guard.require_unheld(&super::config::all_container_names(package_id))?;
|
||||
// A cuprate node that starts on a too-small disk fills it and takes
|
||||
// Archipelago down with it (no upstream pruning — see
|
||||
// dependencies::check_cuprate_disk_compatibility). Fail the start
|
||||
@@ -104,9 +108,10 @@ impl RpcHandler {
|
||||
let op_lock = app_op_lock(package_id);
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let _lifecycle_guard = lifecycle_guard;
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await
|
||||
do_orchestrator_package_start(orchestrator.as_ref(), &to_start, &data_dir).await
|
||||
} else {
|
||||
do_package_start(&to_start).await
|
||||
};
|
||||
@@ -126,7 +131,19 @@ impl RpcHandler {
|
||||
Err(e) => {
|
||||
tracing::error!("package.start {} failed: {:#}", package_id_owned, e);
|
||||
install_log(&format!("START FAIL: {} — {:#}", package_id_owned, e)).await;
|
||||
if let Some(prev) = pre_state {
|
||||
if e.downcast_ref::<crate::container::prod_orchestrator::StagedCleanupFailure>()
|
||||
.is_some()
|
||||
{
|
||||
// Installed is neutral and scanner-owned. Restoring the
|
||||
// prior Stopped state would conceal a failed cleanup;
|
||||
// keeping Starting would prevent scanner convergence.
|
||||
set_package_state(
|
||||
&state_manager,
|
||||
&package_id_owned,
|
||||
PackageState::Installed,
|
||||
)
|
||||
.await;
|
||||
} else if let Some(prev) = pre_state {
|
||||
set_package_state(&state_manager, &package_id_owned, prev).await;
|
||||
} else {
|
||||
warn!(
|
||||
@@ -155,6 +172,10 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
let lifecycle_guard =
|
||||
crate::container::update_transaction::Guard::acquire(&self.config.data_dir)?;
|
||||
lifecycle_guard.require_clear()?;
|
||||
lifecycle_guard.require_unheld(&super::config::all_container_names(package_id))?;
|
||||
|
||||
let single_orchestrator_app =
|
||||
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
|
||||
@@ -218,10 +239,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
let op_lock = app_op_lock(package_id);
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let _lifecycle_guard = lifecycle_guard;
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await
|
||||
do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop, &data_dir).await
|
||||
} else {
|
||||
do_package_stop(&containers).await
|
||||
};
|
||||
@@ -257,6 +280,10 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
let lifecycle_guard =
|
||||
crate::container::update_transaction::Guard::acquire(&self.config.data_dir)?;
|
||||
lifecycle_guard.require_clear()?;
|
||||
lifecycle_guard.require_unheld(&super::config::all_container_names(package_id))?;
|
||||
// Restart is stop + recreate, so on a disk that shrank below the cuprate
|
||||
// minimum after install it resumes the doomed unprunable sync just like
|
||||
// start would — same gate, same "fail before clearing user-stopped /
|
||||
@@ -319,9 +346,10 @@ impl RpcHandler {
|
||||
let op_lock = app_op_lock(package_id);
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let _lifecycle_guard = lifecycle_guard;
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await
|
||||
do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart, &data_dir).await
|
||||
} else {
|
||||
do_package_restart(&containers).await
|
||||
};
|
||||
@@ -362,6 +390,10 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
let lifecycle_guard =
|
||||
crate::container::update_transaction::Guard::acquire(&self.config.data_dir)?;
|
||||
lifecycle_guard.require_clear()?;
|
||||
lifecycle_guard.require_unheld(&super::config::all_container_names(package_id))?;
|
||||
let preserve_data = params
|
||||
.get("preserve_data")
|
||||
.and_then(|v| v.as_bool())
|
||||
@@ -841,17 +873,25 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
async fn do_orchestrator_package_start(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
to_start: &[String],
|
||||
data_dir: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
for name in to_start {
|
||||
orchestrator.validate_start(name).await?;
|
||||
}
|
||||
let mut errors = Vec::new();
|
||||
for (i, name) in to_start.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
repair_before_package_start(name).await;
|
||||
wait_before_package_start(name).await;
|
||||
let managed =
|
||||
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
|
||||
if !managed {
|
||||
repair_before_package_start(name).await;
|
||||
wait_before_package_start(name).await;
|
||||
}
|
||||
match orchestrator.start(name).await {
|
||||
Ok(()) => wait_after_orchestrator_start(name).await,
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
Err(e) if !managed && is_unknown_app_id_error(&e) => {
|
||||
// Collect instead of `?`: aborting here skipped every later
|
||||
// stack member (mempool gate flakes #73/#74 — see
|
||||
// order_present_containers).
|
||||
@@ -871,7 +911,9 @@ async fn do_orchestrator_package_start(
|
||||
Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")))
|
||||
} else {
|
||||
for name in to_start {
|
||||
ensure_runtime_host_port_listener(name).await?;
|
||||
if crate::container::supervised_update::installed_unit(data_dir, name)?.is_none() {
|
||||
ensure_runtime_host_port_listener(name).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1100,12 +1142,15 @@ async fn podman_start_container(container_name: &str) -> Result<Output> {
|
||||
async fn do_orchestrator_package_stop(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
containers: &[String],
|
||||
data_dir: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
for name in containers {
|
||||
let managed =
|
||||
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
|
||||
match orchestrator.stop(name).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
Err(e) if !managed && is_unknown_app_id_error(&e) => {
|
||||
if let Err(e) = do_package_stop(&[name.clone()]).await {
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
}
|
||||
@@ -1114,7 +1159,7 @@ async fn do_orchestrator_package_stop(
|
||||
// stop removes the --rm container before the orchestrator's
|
||||
// defensive `podman stop` fallback probes it, and a stack member
|
||||
// can be absent outright (stopped earlier, or never revived).
|
||||
Err(e) if is_missing_container_error(&format!("{:#}", e)) => {
|
||||
Err(e) if !managed && is_missing_container_error(&format!("{:#}", e)) => {
|
||||
tracing::debug!(container = %name, error = %e, "stop: container already absent — treating as stopped");
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1200,7 +1245,7 @@ async fn cascade_restart_address_caching_dependents(
|
||||
let prev = flip_package_state(state_manager, dep, PackageState::Restarting).await;
|
||||
let target = vec![dep.to_string()];
|
||||
let result = if let Some(orch) = orchestrator {
|
||||
do_orchestrator_package_restart(orch.as_ref(), &target).await
|
||||
do_orchestrator_package_restart(orch.as_ref(), &target, data_dir).await
|
||||
} else {
|
||||
do_package_restart(&target).await
|
||||
};
|
||||
@@ -1268,9 +1313,14 @@ fn uses_single_orchestrator_app(package_id: &str) -> bool {
|
||||
async fn do_orchestrator_package_restart(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
to_restart: &[String],
|
||||
data_dir: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
do_orchestrator_package_stop(orchestrator, to_restart).await?;
|
||||
do_orchestrator_package_start(orchestrator, to_restart).await
|
||||
for name in to_restart {
|
||||
orchestrator.validate_start(name).await?;
|
||||
}
|
||||
let stop_order: Vec<String> = to_restart.iter().rev().cloned().collect();
|
||||
do_orchestrator_package_stop(orchestrator, &stop_order, data_dir).await?;
|
||||
do_orchestrator_package_start(orchestrator, to_restart, data_dir).await
|
||||
}
|
||||
|
||||
/// Stop all containers with their per-container graceful-shutdown timeout.
|
||||
@@ -1929,9 +1979,9 @@ pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
||||
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
||||
dirs.push(root.into());
|
||||
}
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||
}
|
||||
// Cargo exposes this at compile time, not when an already-built isolated
|
||||
// test executable is launched from a different working directory.
|
||||
dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"));
|
||||
dirs.extend([
|
||||
Path::new("apps").to_path_buf(),
|
||||
Path::new("/opt/archipelago/apps").to_path_buf(),
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
//! remove old container(s) → recreate (orchestrator-first, legacy fallback) → verify running.
|
||||
//! Data volumes are preserved (bind mounts, not stored in container).
|
||||
|
||||
use super::config::get_containers_for_app;
|
||||
use super::config::{all_container_names, get_containers_for_app};
|
||||
use super::install::install_log;
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::runtime::stop_timeout_secs;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::container::image_versions;
|
||||
use crate::data_model::{InstallPhase, PackageState};
|
||||
use anyhow::{Context, Result};
|
||||
use std::{collections::HashSet, path::Path};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
@@ -31,6 +31,9 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
let lifecycle_guard =
|
||||
crate::container::update_transaction::Guard::acquire(&self.config.data_dir)?;
|
||||
lifecycle_guard.require_clear()?;
|
||||
|
||||
// An Update click must not act on an hourly cache that predates the
|
||||
// button. Fetch and verify first; failure leaves running containers alone.
|
||||
@@ -60,9 +63,24 @@ impl RpcHandler {
|
||||
let targets = pinned
|
||||
.as_ref()
|
||||
.map(|target| self.resolve_images_to_pull(package_id, target));
|
||||
// A stopped Quadlet normally removes its --rm container. Absence is
|
||||
// not an install decision: only a known single managed app with durable
|
||||
// installed AND stopped evidence may enter the recreate path.
|
||||
let known_managed =
|
||||
if should_try_orchestrator_update(package_id, self.orchestrator.is_some()) {
|
||||
self.orchestrator
|
||||
.as_ref()
|
||||
.expect("orchestrator presence checked")
|
||||
.knows_app(orchestrator_update_app_id(package_id))
|
||||
.await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let markers = UpdateMarkers::load(&self.config.data_dir, package_id).await?;
|
||||
let installed = inspect_update_images(package_id).await?;
|
||||
validate_update_presence(package_id, known_managed, !installed.is_empty(), &markers)?;
|
||||
if let Some(targets) = &targets {
|
||||
let installed = inspect_update_images(package_id).await?;
|
||||
if !update_targets_need_change(targets, &installed)? {
|
||||
if !update_targets_need_change(targets, &installed)? && !markers.stopped {
|
||||
install_log(&format!(
|
||||
"UPDATE SKIP: {} — target versions already installed",
|
||||
package_id
|
||||
@@ -111,6 +129,19 @@ impl RpcHandler {
|
||||
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||
Ok(()) => {
|
||||
if let Some(image) = orchestrator
|
||||
.staged_upgrade_image(orchestrator_app_id)
|
||||
.await?
|
||||
{
|
||||
// The orchestrator proved the pinned image exists and
|
||||
// persisted its reviewed manifest. No running container
|
||||
// or healthy service is claimed for a stopped update.
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Ok(serde_json::json!({
|
||||
"status": "staged", "state": "stopped",
|
||||
"package_id": package_id, "image": image,
|
||||
}));
|
||||
}
|
||||
if let Some(targets) = &targets {
|
||||
verify_update_targets(
|
||||
targets,
|
||||
@@ -173,17 +204,48 @@ impl RpcHandler {
|
||||
let images_to_pull =
|
||||
targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned));
|
||||
|
||||
// Get all containers for this app
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
self.clear_update_state(package_id).await;
|
||||
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||
// Managed imports are intentionally private. Resolve the trusted catalog
|
||||
// against the reviewed local digest plan before any registry preparation;
|
||||
// never pull mutable dependency tags over an approved local plan. Every
|
||||
// preparation refusal clears Updating, including inventory/plan failures.
|
||||
let preparation = async {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
anyhow::ensure!(
|
||||
!containers.is_empty(),
|
||||
"No containers found for {}",
|
||||
package_id
|
||||
);
|
||||
let images = self
|
||||
.reviewed_managed_image_references(
|
||||
package_id,
|
||||
&containers,
|
||||
&images_to_pull,
|
||||
&lifecycle_guard,
|
||||
)
|
||||
.await?;
|
||||
Ok::<_, anyhow::Error>((containers, images))
|
||||
}
|
||||
.await;
|
||||
let (containers, images_to_pull) = match preparation {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
self.clear_install_progress(package_id).await;
|
||||
self.clear_update_state(package_id).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute update — on failure, attempt rollback by restarting old containers
|
||||
match self
|
||||
.execute_update(package_id, &containers, &images_to_pull)
|
||||
.await
|
||||
// Resolve every image while the old stack is still available. A
|
||||
// registry outage or missing private import must not stop the app or
|
||||
// enter rollback (which could start a deliberately stopped member).
|
||||
self.set_install_phase(package_id, InstallPhase::PullingImage)
|
||||
.await;
|
||||
match preflighted_stack_update(
|
||||
&images_to_pull,
|
||||
|image| async move { self.pull_update_image(package_id, &image).await },
|
||||
|| self.execute_update(package_id, &containers, &images_to_pull, &lifecycle_guard),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
install_log(&format!("UPDATE OK: {}", package_id)).await;
|
||||
@@ -193,17 +255,26 @@ impl RpcHandler {
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
Err(UpdateFailure::Preparation(error)) => {
|
||||
self.clear_install_progress(package_id).await;
|
||||
self.clear_update_state(package_id).await;
|
||||
Err(error)
|
||||
}
|
||||
Err(UpdateFailure::Execution(e)) => {
|
||||
error!("Update {} failed: {}. Attempting rollback.", package_id, e);
|
||||
install_log(&format!(
|
||||
"UPDATE FAIL: {} — {}. Rolling back.",
|
||||
package_id, e
|
||||
))
|
||||
.await;
|
||||
self.rollback_update(package_id, &containers).await;
|
||||
// Transaction executor already recovered original identities or
|
||||
// returned an explicit unresolved state. Never guess/reinstall.
|
||||
self.clear_install_progress(package_id).await;
|
||||
self.clear_update_state(package_id).await;
|
||||
Err(e.context(format!("Update {} failed, rolled back", package_id)))
|
||||
Err(e.context(format!(
|
||||
"Update {} failed; see retained-runtime recovery result",
|
||||
package_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,137 +319,110 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Core update execution: stop → pull → remove → recreate → verify.
|
||||
/// Images are prepared first. The transaction preserves original identities,
|
||||
/// creates stopped replacements, and restores the exact old states on failure.
|
||||
async fn execute_update(
|
||||
&self,
|
||||
package_id: &str,
|
||||
containers: &[String],
|
||||
images_to_pull: &[(String, String)],
|
||||
guard: &crate::container::update_transaction::Guard,
|
||||
) -> Result<()> {
|
||||
// Phase: Preparing — about to stop the running container(s) so
|
||||
// we can swap images. Fast.
|
||||
use crate::container::update_transaction::{self, Podman};
|
||||
let mut supervised = 0;
|
||||
for name in containers {
|
||||
if crate::container::quadlet::unit_exists(name).await {
|
||||
supervised += 1;
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(
|
||||
supervised == 0 || supervised == containers.len(),
|
||||
"Mixed managed/unmanaged stack requires an explicit recovery plan; originals unchanged"
|
||||
);
|
||||
let managed = supervised != 0;
|
||||
anyhow::ensure!(
|
||||
!managed || package_id == "indeedhub",
|
||||
"This managed app has no qualified maintenance controller; originals unchanged"
|
||||
);
|
||||
let targets = Podman::targets(images_to_pull, !managed).await?;
|
||||
let names: HashSet<_> = containers.iter().map(String::as_str).collect();
|
||||
anyhow::ensure!(
|
||||
targets.len() == names.len()
|
||||
&& targets
|
||||
.iter()
|
||||
.all(|target| names.contains(target.name.as_str())),
|
||||
"Update target membership differs from installed stack; no containers changed"
|
||||
);
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
if managed {
|
||||
use crate::container::supervised_runtime::{
|
||||
load_reviewed_plans, LegacyIndeeMaintenance, SystemdSupervisor,
|
||||
};
|
||||
let plans = load_reviewed_plans(&self.config.data_dir, package_id)?;
|
||||
let adapter = SystemdSupervisor::new(
|
||||
self.config.data_dir.clone(),
|
||||
plans,
|
||||
LegacyIndeeMaintenance::new(guard)?,
|
||||
)
|
||||
.await?;
|
||||
let targets = adapter.reviewed_targets(&targets).await?;
|
||||
crate::container::supervised_update::execute(guard, package_id, &targets, &adapter)
|
||||
.await
|
||||
} else {
|
||||
update_transaction::execute(guard, package_id, &targets, &Podman).await
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Graceful stop all containers (reverse order for dependencies)
|
||||
info!(
|
||||
"Update {}: stopping {} containers",
|
||||
package_id,
|
||||
containers.len()
|
||||
async fn reviewed_managed_image_references(
|
||||
&self,
|
||||
package_id: &str,
|
||||
containers: &[String],
|
||||
images: &[(String, String)],
|
||||
guard: &crate::container::update_transaction::Guard,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
use crate::container::supervised_runtime::{
|
||||
load_reviewed_plans, LegacyIndeeMaintenance, SystemdSupervisor,
|
||||
};
|
||||
use crate::container::supervised_update::Supervisor;
|
||||
use crate::container::update_transaction::Podman;
|
||||
let mut managed = 0;
|
||||
for name in containers {
|
||||
if crate::container::quadlet::unit_exists(name).await {
|
||||
managed += 1;
|
||||
}
|
||||
}
|
||||
if managed == 0 {
|
||||
return Ok(images.to_vec());
|
||||
}
|
||||
anyhow::ensure!(
|
||||
managed == containers.len() && package_id == "indeedhub",
|
||||
"Managed stack requires its complete qualified maintenance plan; originals unchanged"
|
||||
);
|
||||
for name in containers.iter().rev() {
|
||||
let timeout = stop_timeout_secs(name);
|
||||
info!(
|
||||
"Update {}: stopping {} (timeout: {}s)",
|
||||
package_id, name, timeout
|
||||
);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", timeout, name])
|
||||
.output()
|
||||
.await
|
||||
.context(format!("Failed to stop {}", name))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
warn!(
|
||||
"Update {}: stop {} failed: {}",
|
||||
package_id,
|
||||
name,
|
||||
stderr.trim()
|
||||
);
|
||||
// Continue — container might already be stopped
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: PullingImage — about to fetch each pinned image in turn.
|
||||
self.set_install_phase(package_id, InstallPhase::PullingImage)
|
||||
.await;
|
||||
|
||||
// 2. Pull new images with progress
|
||||
info!(
|
||||
"Update {}: pulling {} images",
|
||||
package_id,
|
||||
images_to_pull.len()
|
||||
let catalog = Podman::targets(images, false).await?;
|
||||
let names: HashSet<_> = containers.iter().map(String::as_str).collect();
|
||||
anyhow::ensure!(
|
||||
catalog.len() == names.len()
|
||||
&& catalog
|
||||
.iter()
|
||||
.all(|target| names.contains(target.name.as_str())),
|
||||
"Reviewed managed target membership differs from installed stack"
|
||||
);
|
||||
for (i, (name, image)) in images_to_pull.iter().enumerate() {
|
||||
info!(
|
||||
"Update {}: pulling image {}/{} ({})",
|
||||
package_id,
|
||||
i + 1,
|
||||
images_to_pull.len(),
|
||||
image
|
||||
);
|
||||
self.pull_update_image(package_id, image)
|
||||
.await
|
||||
.context(format!("Failed to pull {} for {}", image, name))?;
|
||||
let adapter = SystemdSupervisor::new(
|
||||
self.config.data_dir.clone(),
|
||||
load_reviewed_plans(&self.config.data_dir, package_id)?,
|
||||
LegacyIndeeMaintenance::new(guard)?,
|
||||
)
|
||||
.await?;
|
||||
let targets = adapter.reviewed_targets(&catalog).await?;
|
||||
for target in &targets {
|
||||
let original = adapter.capture(&target.name).await?;
|
||||
adapter.prepare_target(target, &original).await?;
|
||||
}
|
||||
|
||||
// 3. Remove old containers
|
||||
info!("Update {}: removing old containers", package_id);
|
||||
for name in containers {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["rm", name])
|
||||
.output()
|
||||
.await
|
||||
.context(format!("Failed to remove {}", name))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
// Force remove as fallback
|
||||
warn!(
|
||||
"Update {}: rm {} failed ({}), forcing",
|
||||
package_id,
|
||||
name,
|
||||
stderr.trim()
|
||||
);
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: CreatingContainer — about to recreate each container.
|
||||
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
// 4. Recreate containers (orchestrator-first, reconcile fallback)
|
||||
info!("Update {}: recreating containers", package_id);
|
||||
for name in containers {
|
||||
self.recreate_container_for_update(package_id, name).await?;
|
||||
// Brief delay between containers for dependency initialization
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
// Phase: WaitingHealthy — reconcile has started every container,
|
||||
// now verifying each reached running state.
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
|
||||
// 5. Verify containers reached running state
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
for name in containers {
|
||||
let status = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.State.Status}}"])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = status {
|
||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
anyhow::ensure!(
|
||||
o.status.success() && state == "running",
|
||||
"Update {}: container {} is not running after recreate",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Update {}: cannot inspect recreated container {}",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
verify_update_targets(images_to_pull, &inspect_update_images(package_id).await?)?;
|
||||
Ok(())
|
||||
Ok(targets
|
||||
.into_iter()
|
||||
.map(|target| (target.name, target.reference))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn recreate_container_for_update(
|
||||
@@ -430,6 +474,32 @@ impl RpcHandler {
|
||||
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
if immutable_update_image(image) {
|
||||
// A digest-addressed lookup asks Podman for these exact bytes,
|
||||
// unlike a mutable tag lookup. Private imports need no registry.
|
||||
let local = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["image", "exists", image])
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.context("Local image lookup timed out; existing app remains unchanged")??;
|
||||
match local.status.code() {
|
||||
Some(0) => {
|
||||
self.set_install_progress(package_id, 100, 100).await;
|
||||
return Ok(());
|
||||
}
|
||||
Some(1) => {}
|
||||
_ => anyhow::bail!("Cannot inspect local image storage; update cancelled"),
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!image.starts_with("localhost/"),
|
||||
"The exact private image must be imported before updating this app"
|
||||
);
|
||||
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if archipelago_container::image_uses_insecure_registry(image) {
|
||||
@@ -511,61 +581,84 @@ impl RpcHandler {
|
||||
stack_images
|
||||
}
|
||||
|
||||
/// Rollback: restart old containers if they still exist.
|
||||
/// Called when update fails partway through.
|
||||
async fn rollback_update(&self, package_id: &str, containers: &[String]) {
|
||||
warn!("Rolling back update for {}", package_id);
|
||||
for name in containers {
|
||||
// Try to start — works if container still exists (wasn't removed yet)
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["start", name])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
info!("Rollback: restarted {}", name);
|
||||
}
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
warn!("Rollback: could not restart {}: {}", name, stderr.trim());
|
||||
// Container was already removed (forward path ran `podman rm`).
|
||||
// Recreate via orchestrator-first path with legacy fallback.
|
||||
if let Err(recreate_err) =
|
||||
self.recreate_container_for_update(package_id, name).await
|
||||
{
|
||||
error!(
|
||||
"Rollback: failed to recreate {} during rollback of {}: {}",
|
||||
name, package_id, recreate_err
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Rollback: failed to restart {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the Updating state (used on failure/rollback).
|
||||
async fn clear_update_state(&self, package_id: &str) {
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
// Don't overwrite state from scanner — just clear if still Updating
|
||||
if entry.state == PackageState::Updating {
|
||||
entry.state = PackageState::Stopped;
|
||||
// Unknown is not stopped: the authoritative scanner will
|
||||
// refresh actual retained runtime immediately in the wrapper.
|
||||
entry.state = PackageState::Installed;
|
||||
}
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn inspect_update_images(package_id: &str) -> Result<Vec<(String, String)>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
#[derive(Default)]
|
||||
struct UpdateMarkers {
|
||||
installed: bool,
|
||||
stopped: bool,
|
||||
uninstalled: bool,
|
||||
}
|
||||
|
||||
impl UpdateMarkers {
|
||||
async fn load(data_dir: &Path, package_id: &str) -> Result<Self> {
|
||||
let mut names = all_container_names(package_id);
|
||||
names.push(package_id.to_string());
|
||||
names.push(orchestrator_update_app_id(package_id).to_string());
|
||||
let installed = read_update_markers(data_dir, "installed-apps.json").await?;
|
||||
let stopped = read_update_markers(data_dir, "user-stopped.json").await?;
|
||||
let uninstalled = read_update_markers(data_dir, "user-uninstalled.json").await?;
|
||||
Ok(Self {
|
||||
installed: names.iter().any(|name| installed.contains(name)),
|
||||
stopped: names.iter().any(|name| stopped.contains(name)),
|
||||
uninstalled: names.iter().any(|name| uninstalled.contains(name)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The recovery loaders intentionally return empty on malformed state. An update
|
||||
// must instead distinguish unavailable evidence from a proven absence of an
|
||||
// uninstall decision before it can recreate a removed Quadlet container.
|
||||
async fn read_update_markers(data_dir: &Path, filename: &str) -> Result<HashSet<String>> {
|
||||
match tokio::fs::read(data_dir.join(filename)).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("Cannot read {filename}; update cancelled")),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(HashSet::new()),
|
||||
Err(error) => {
|
||||
Err(error).with_context(|| format!("Cannot read {filename}; update cancelled"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_update_presence(
|
||||
package_id: &str,
|
||||
known_managed: bool,
|
||||
has_containers: bool,
|
||||
markers: &UpdateMarkers,
|
||||
) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!containers.is_empty(),
|
||||
"No containers found for {}",
|
||||
!markers.uninstalled,
|
||||
"{} was uninstalled; use an explicit install instead of update",
|
||||
package_id
|
||||
);
|
||||
anyhow::ensure!(
|
||||
has_containers || (known_managed && markers.installed && markers.stopped),
|
||||
"No containers or durable stopped installation found for {}",
|
||||
package_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inspect_update_images(package_id: &str) -> Result<Vec<(String, String)>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
// The caller decides whether durable installation evidence authorizes
|
||||
// a missing runtime. Never invoke bare `podman inspect` here.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.arg("inspect").args(&containers).kill_on_drop(true);
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
|
||||
@@ -611,6 +704,7 @@ fn verify_update_targets(
|
||||
targets: &[(String, String)],
|
||||
installed: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
anyhow::ensure!(!targets.is_empty(), "No update targets resolved");
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed).ok_or_else(|| {
|
||||
anyhow::anyhow!("Update {}: target container missing after recreate", app_id)
|
||||
@@ -636,6 +730,7 @@ fn update_targets_need_change(
|
||||
installed: &[(String, String)],
|
||||
) -> Result<bool> {
|
||||
use std::cmp::Ordering;
|
||||
anyhow::ensure!(!targets.is_empty(), "No update targets resolved");
|
||||
let mut changed = false;
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed);
|
||||
@@ -655,6 +750,44 @@ fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool
|
||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
||||
}
|
||||
|
||||
fn immutable_update_image(image: &str) -> bool {
|
||||
image.rsplit_once("@sha256:").is_some_and(|(name, digest)| {
|
||||
!name.is_empty()
|
||||
&& !name.contains('@')
|
||||
&& digest.len() == 64
|
||||
&& digest.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum UpdateFailure {
|
||||
Preparation(anyhow::Error),
|
||||
Execution(anyhow::Error),
|
||||
}
|
||||
|
||||
/// A preparation failure never enters the lifecycle/rollback path. Keep this
|
||||
/// sequencing injectable so failed second-image pulls are tested without apps.
|
||||
async fn preflighted_stack_update<P, PF, E, EF>(
|
||||
images: &[(String, String)],
|
||||
mut prepare: P,
|
||||
execute: E,
|
||||
) -> std::result::Result<(), UpdateFailure>
|
||||
where
|
||||
P: FnMut(String) -> PF,
|
||||
PF: std::future::Future<Output = Result<()>>,
|
||||
E: FnOnce() -> EF,
|
||||
EF: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
for (name, image) in images {
|
||||
prepare(image.clone()).await.map_err(|error| {
|
||||
UpdateFailure::Preparation(error.context(format!(
|
||||
"Cannot prepare image for {name}; existing containers were left unchanged"
|
||||
)))
|
||||
})?;
|
||||
}
|
||||
execute().await.map_err(UpdateFailure::Execution)
|
||||
}
|
||||
|
||||
fn orchestrator_update_app_id(package_id: &str) -> &str {
|
||||
match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
@@ -716,11 +849,182 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
||||
candidate_app_ids_for_container, immutable_update_image, orchestrator_update_app_id,
|
||||
should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow,
|
||||
verify_update_targets,
|
||||
validate_update_presence, verify_update_targets, UpdateMarkers,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn stopped_installed_managed_app_updates_after_quadlet_container_disappears() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
crate::crash_recovery::mark_installed(root.path(), "node-demo-music").await;
|
||||
crate::crash_recovery::mark_user_stopped(root.path(), "node-demo-music").await;
|
||||
let markers = UpdateMarkers::load(root.path(), "node-demo-music")
|
||||
.await
|
||||
.unwrap();
|
||||
validate_update_presence("node-demo-music", true, false, &markers).unwrap();
|
||||
let target = vec![("node-demo-music".into(), "localhost/music:2".into())];
|
||||
assert!(super::update_targets_need_change(&target, &[]).unwrap());
|
||||
// Success must still prove that upgrade actually created the target.
|
||||
assert!(verify_update_targets(&target, &[]).is_err());
|
||||
assert!(verify_update_targets(&target, &target).is_ok());
|
||||
assert!(crate::crash_recovery::load_user_stopped(root.path())
|
||||
.await
|
||||
.contains("node-demo-music"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_catalog_app_or_unmanaged_runtime_is_not_an_update_installation() {
|
||||
for (managed, installed, stopped) in [
|
||||
(true, false, false),
|
||||
(true, false, true),
|
||||
(true, true, false),
|
||||
(false, true, true),
|
||||
] {
|
||||
let markers = UpdateMarkers {
|
||||
installed,
|
||||
stopped,
|
||||
uninstalled: false,
|
||||
};
|
||||
assert!(validate_update_presence("optional", managed, false, &markers).is_err());
|
||||
}
|
||||
// Existing legacy containers remain updateable without modern markers.
|
||||
assert!(validate_update_presence("legacy", false, true, &UpdateMarkers::default()).is_ok());
|
||||
assert!(super::update_targets_need_change(&[], &[]).is_err());
|
||||
assert!(verify_update_targets(&[], &[]).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_tombstone_beats_stale_installed_and_stopped_markers() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
crate::crash_recovery::mark_installed(root.path(), "node-demo-music").await;
|
||||
crate::crash_recovery::mark_user_stopped(root.path(), "node-demo-music").await;
|
||||
crate::crash_recovery::mark_user_uninstalled(root.path(), "archy-node-demo-music").await;
|
||||
let markers = UpdateMarkers::load(root.path(), "node-demo-music")
|
||||
.await
|
||||
.unwrap();
|
||||
for present in [false, true] {
|
||||
assert!(validate_update_presence("node-demo-music", true, present, &markers).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn damaged_lifecycle_markers_cannot_authorize_recreation() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let absent = UpdateMarkers::load(root.path(), "optional").await.unwrap();
|
||||
assert!(validate_update_presence("optional", true, false, &absent).is_err());
|
||||
for name in [
|
||||
"installed-apps.json",
|
||||
"user-stopped.json",
|
||||
"user-uninstalled.json",
|
||||
] {
|
||||
tokio::fs::write(root.path().join(name), b"not-json")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(UpdateMarkers::load(root.path(), "optional").await.is_err());
|
||||
tokio::fs::remove_file(root.path().join(name))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indeehub_update_resolves_every_member_in_dependency_order() {
|
||||
let members = crate::container::image_versions::containers_for_stack("indeedhub");
|
||||
let expected = super::all_container_names("indeedhub");
|
||||
assert_eq!(
|
||||
members.iter().map(|(name, _)| *name).collect::<Vec<_>>(),
|
||||
expected
|
||||
);
|
||||
let source = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../scripts/image-versions.sh"
|
||||
));
|
||||
for (_, variable) in members {
|
||||
assert!(
|
||||
source
|
||||
.lines()
|
||||
.any(|line| line.starts_with(&format!("{variable}="))),
|
||||
"Missing image pin {variable}"
|
||||
);
|
||||
}
|
||||
assert!(super::uses_legacy_update_flow("indeedhub"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stack_image_failure_precedes_every_lifecycle_action() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
for failed_second in [true, false] {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let preparing = calls.clone();
|
||||
let executing = calls.clone();
|
||||
let images = vec![
|
||||
("web".into(), "web-image".into()),
|
||||
("api".into(), "api-image".into()),
|
||||
];
|
||||
let result = super::preflighted_stack_update(
|
||||
&images,
|
||||
move |image| {
|
||||
let preparing = preparing.clone();
|
||||
async move {
|
||||
preparing.lock().unwrap().push(format!("prepare:{image}"));
|
||||
anyhow::ensure!(!(failed_second && image == "api-image"), "import missing");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
move || async move {
|
||||
executing.lock().unwrap().extend([
|
||||
"stop".into(),
|
||||
"remove".into(),
|
||||
"start".into(),
|
||||
]);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if failed_second {
|
||||
assert!(matches!(result, Err(super::UpdateFailure::Preparation(_))));
|
||||
assert_eq!(
|
||||
*calls.lock().unwrap(),
|
||||
["prepare:web-image", "prepare:api-image"]
|
||||
);
|
||||
} else {
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
*calls.lock().unwrap(),
|
||||
[
|
||||
"prepare:web-image",
|
||||
"prepare:api-image",
|
||||
"stop",
|
||||
"remove",
|
||||
"start"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_exact_digest_refs_may_skip_update_registry_pull() {
|
||||
let digest = "ab".repeat(32);
|
||||
assert!(immutable_update_image(&format!(
|
||||
"localhost/lfg2025/indeedhub:1.0.1@sha256:{digest}"
|
||||
)));
|
||||
assert!(immutable_update_image(&format!(
|
||||
"registry.example/app@sha256:{digest}"
|
||||
)));
|
||||
for mutable_or_invalid in [
|
||||
"localhost/lfg2025/indeedhub:1.0.1".to_string(),
|
||||
"registry.example/app:latest".to_string(),
|
||||
format!("registry.example/app@sha256:{}", "g".repeat(64)),
|
||||
"registry.example/app@sha256:abcd".to_string(),
|
||||
format!("@sha256:{digest}"),
|
||||
format!("registry.example/app@other@sha256:{digest}"),
|
||||
] {
|
||||
assert!(!immutable_update_image(&mutable_or_invalid));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() {
|
||||
let installed = vec![(
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Native broker only: settled purchases become session-bound opaque media URLs.
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Issue {
|
||||
purchase_id: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Status {
|
||||
handle: String,
|
||||
}
|
||||
impl RpcHandler {
|
||||
async fn playback_context(
|
||||
&self,
|
||||
session: &Option<String>,
|
||||
) -> Result<(
|
||||
String,
|
||||
crate::container::registration_pin::InstalledAppContext,
|
||||
)> {
|
||||
let session = session.as_ref().context("Owner session required")?;
|
||||
anyhow::ensure!(
|
||||
self.session_store.validate(session).await,
|
||||
"Owner session expired"
|
||||
);
|
||||
let identity =
|
||||
crate::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??;
|
||||
Ok((session.clone(), context))
|
||||
}
|
||||
pub(super) async fn handle_playback_handle(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
session: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params: Issue = serde_json::from_value(params.context("Missing purchase identifier")?)?;
|
||||
let (session, context) = self.playback_context(session).await?;
|
||||
let handle = self
|
||||
.playback_handles()
|
||||
.issue(
|
||||
&self.config.data_dir,
|
||||
context.clone(),
|
||||
&session,
|
||||
¶ms.purchase_id,
|
||||
)
|
||||
.await?;
|
||||
let expires = self
|
||||
.playback_handles()
|
||||
.expiry(&handle, &session, &context)?;
|
||||
Ok(serde_json::json!({"handle":handle,"expires_at":expires}))
|
||||
}
|
||||
pub(super) async fn handle_playback_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
session: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params: Status = serde_json::from_value(params.context("Missing playback handle")?)?;
|
||||
let (session, context) = self.playback_context(session).await?;
|
||||
let expires = self
|
||||
.playback_handles()
|
||||
.expiry(¶ms.handle, &session, &context)?;
|
||||
Ok(serde_json::json!({"expires_at":expires}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Prepare {
|
||||
handle: String,
|
||||
#[serde(default)]
|
||||
retry: bool,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Start {
|
||||
handle: String,
|
||||
ready_id: String,
|
||||
}
|
||||
impl RpcHandler {
|
||||
async fn playback_control(
|
||||
&self,
|
||||
handle: &str,
|
||||
ready_id: Option<&str>,
|
||||
retry: bool,
|
||||
session: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (session, context) = self.playback_context(session).await?;
|
||||
let binding = self.playback_handles().lookup(handle, &session, &context)?;
|
||||
let (capability, duration) = {
|
||||
let journal = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
|
||||
let record = journal
|
||||
.buyer(&binding.contract.id)
|
||||
.await?
|
||||
.context("Original purchase missing")?;
|
||||
anyhow::ensure!(
|
||||
record.contract == binding.contract,
|
||||
"Original purchase changed"
|
||||
);
|
||||
let capability = record
|
||||
.receipt()
|
||||
.context("Original payment is not settled")?
|
||||
.capability
|
||||
.clone();
|
||||
let envelope = journal
|
||||
.protocol_envelope("buyer", &binding.contract.id)
|
||||
.await?
|
||||
.context("Original rental terms missing")?;
|
||||
(
|
||||
capability,
|
||||
envelope
|
||||
.offer
|
||||
.viewing_seconds
|
||||
.context("Purchase is not a timed rental")?,
|
||||
)
|
||||
};
|
||||
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 unavailable")?;
|
||||
let action = if ready_id.is_some() {
|
||||
"start"
|
||||
} else {
|
||||
"prepare"
|
||||
};
|
||||
let path = format!(
|
||||
"/content/{}/rental/{}/{action}",
|
||||
binding.contract.content_id, binding.contract.id
|
||||
);
|
||||
let body = serde_json::json!({"capability":capability,"ready_id":ready_id,"retry":retry});
|
||||
let (mut response, _) =
|
||||
crate::fips::dial::PeerRequest::new(Some(&mesh), &binding.seller_onion, &path)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.send_rental_control_json(
|
||||
&self.config.data_dir,
|
||||
&binding.contract.seller_did,
|
||||
&body,
|
||||
)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Original rental is unavailable; retry this purchase without paying again"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
anyhow::ensure!(
|
||||
bytes
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
.is_some_and(|n| n <= 16 * 1024),
|
||||
"Rental control response too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let remote: serde_json::Value = serde_json::from_slice(&bytes)?;
|
||||
let state = remote["state"].as_str().context("Missing rental state")?;
|
||||
let window = match (remote["started_at"].as_u64(), remote["expires_at"].as_u64()) {
|
||||
(None, None) if remote["started_at"].is_null() && remote["expires_at"].is_null() => {
|
||||
None
|
||||
}
|
||||
(Some(started), Some(expires))
|
||||
if started > 0 && started.checked_add(duration) == Some(expires) =>
|
||||
{
|
||||
Some((started, expires))
|
||||
}
|
||||
_ => anyhow::bail!("Seller changed the original rental window"),
|
||||
};
|
||||
let (started_at, expires_at) =
|
||||
window.map_or((None, None), |(start, end)| (Some(start), Some(end)));
|
||||
let result = match state {
|
||||
"preparing" if ready_id.is_none() => {
|
||||
let completed = remote["completed_bytes"]
|
||||
.as_u64()
|
||||
.context("Invalid verification progress")?;
|
||||
anyhow::ensure!(
|
||||
remote["total_bytes"].as_u64() == Some(binding.contract.content_size)
|
||||
&& completed <= binding.contract.content_size
|
||||
&& remote["viewing_seconds"].as_u64() == Some(duration),
|
||||
"Rental preparation terms changed"
|
||||
);
|
||||
serde_json::json!({"state":"preparing","completed_bytes":completed,"total_bytes":binding.contract.content_size,
|
||||
"viewing_seconds":duration,"started_at":started_at,"expires_at":expires_at})
|
||||
}
|
||||
"ready" if ready_id.is_none() => {
|
||||
let id = remote["ready_id"]
|
||||
.as_str()
|
||||
.context("Missing readiness identifier")?;
|
||||
let parsed = uuid::Uuid::parse_str(id)?;
|
||||
anyhow::ensure!(
|
||||
parsed.to_string() == id
|
||||
&& parsed.get_version_num() == 4
|
||||
&& remote["total_bytes"].as_u64() == Some(binding.contract.content_size)
|
||||
&& remote["viewing_seconds"].as_u64() == Some(duration),
|
||||
"Rental readiness changed"
|
||||
);
|
||||
serde_json::json!({"state":"ready","ready_id":id,"handle":handle,"viewing_seconds":duration,
|
||||
"started_at":started_at,"expires_at":expires_at})
|
||||
}
|
||||
"unavailable" if ready_id.is_none() => {
|
||||
serde_json::json!({"state":"unavailable","expires_at":expires_at})
|
||||
}
|
||||
"expired" if window.is_some() => {
|
||||
serde_json::json!({"state":"expired","started_at":started_at,"expires_at":expires_at})
|
||||
}
|
||||
"started" if ready_id.is_some() && window.is_some() => {
|
||||
serde_json::json!({"state":"started","started_at":started_at,
|
||||
"expires_at":expires_at,"playback_url":format!("/api/rental-playback/{handle}")})
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!("Unexpected rental state; recover this purchase without paying again")
|
||||
}
|
||||
};
|
||||
if let Some((_, expires)) = window {
|
||||
self.playback_handles()
|
||||
.note_expiry(handle, &binding, expires)?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub(super) async fn handle_playback_prepare(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
session: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let input: Prepare = serde_json::from_value(params.context("Missing playback handle")?)?;
|
||||
self.playback_control(&input.handle, None, input.retry, session)
|
||||
.await
|
||||
}
|
||||
pub(super) async fn handle_playback_start(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
session: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let input: Start = serde_json::from_value(params.context("Missing readiness identifier")?)?;
|
||||
self.playback_control(&input.handle, Some(&input.ready_id), false, session)
|
||||
.await
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user