Compare commits

..
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 edcce5a308 feat(nostr-vpn): package paid-exit seller + web control panel as manifest apps
Phase 1 of docs/nostr-vpn-integration-plan.md's Phase 0->4 plan (seller-side
rootless feasibility already confirmed there). Two apps, one image:

- apps/nostr-vpn: the daemon. Own network namespace (container.network:
  pasta), NET_ADMIN+NET_RAW scoped to that netns, /dev/net/tun, and the
  net.ipv4.ip_forward sysctl via the primitive added in e42bd26. UDP 51822
  (not upstream's default 51820, which collides with archipelago-wg on
  fleet nodes per the Phase 0 log). Seller mode stays off until an operator
  explicitly enables it (paid_exit.enabled defaults to false upstream).
- apps/nostr-vpn-web: the control panel, gated behind 127.0.0.1:38080,
  talking to the daemon only through the shared /data volume (state-file
  status + shelling out to the nvpn CLI) -- no network link between the
  two containers, matching upstream's own umbrel/docker-compose.yml.
- docker/nostr-vpn: upstream's umbrel/Dockerfile, unchanged except for how
  the pinned commit arrives (shallow git fetch of a verified SHA, since
  codeload.github.com archive tarballs 404 from this environment and
  GitHub won't fetch an arbitrary SHA directly). Entrypoint seeds a minimal
  config.toml with the chosen listen_port on first boot only -- every
  AppConfig field is `serde(default = ...)`, confirmed by reading
  nostr-vpn-core directly, so this merges with nvpn's own identity/wallet
  bootstrap instead of needing a generated_secrets entry or full config
  template, and never touches a config that already exists.

Both volumes point at /var/lib/archipelago/nostr-vpn, adopting state from
the old root-mode install. Build and the seed-config path were verified
against the real `nvpn daemon` binary, not just read -- see the plan doc's
Phase 1 log for what that caught (a fabricated commit SHA, the codeload
404, wrong default branch name, and confirming identity/wallet persistence
actually survives container recreation).

Not done here, flagged in the plan doc instead: removing the old root-mode
path (rpc/vpn.rs, rpc/auth.rs's auto-enable-on-login) touches live
onboarding on every node, not just this app -- needs explicit sign-off.
Also missing: a stop-hook/uninstall-guard manifest primitive (doesn't
exist yet -- LifecycleHooks only has post_install/pre_start) for the
collect-due-on-stop and non-zero-wallet uninstall guard, and registry
mirroring + catalog signing (need credentials this pass doesn't have).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 00:29:51 +00:00
ssmithxandClaude Opus 5.5 e42bd26ec7 feat(manifest): add allow-listed per-netns sysctls primitive
Routing apps (a rootless VPN exit) need packet forwarding in their own
network namespace, but /proc/sys is read-only inside a rootless
container, so it can only be set at create time. Add `app.sysctls`,
allow-listed to net.ipv4.ip_forward / net.ipv6.conf.all.forwarding with
values "0"/"1", and rejected under host networking where it would change
the host. Rendered on all three create paths: podman CLI --sysctl, the
libpod spec `sysctl` map, and Quadlet `Sysctl=`. Absent by default and
not serialized when empty, so existing manifests and units are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:16:53 +00:00
893 changed files with 5145 additions and 123382 deletions
-49
View File
@@ -1,49 +0,0 @@
---
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)
@@ -1,7 +0,0 @@
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
@@ -1,17 +0,0 @@
# 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.
@@ -1,41 +0,0 @@
---
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)
@@ -1,7 +0,0 @@
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
@@ -1,15 +0,0 @@
# 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.
@@ -1,18 +0,0 @@
# 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.
-53
View File
@@ -1,53 +0,0 @@
---
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)
@@ -1,7 +0,0 @@
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
@@ -1,10 +0,0 @@
# 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.
@@ -1,19 +0,0 @@
# 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.
+1 -7
View File
@@ -4,13 +4,7 @@
# Allow neode-ui (frontend + mock backend + docker configs)
!neode-ui/
!aiui/
aiui/**/node_modules
aiui/**/dist
aiui/**/.turbo
aiui/**/.build-aiui-last-*
# Allow curated demo assets
# Allow demo assets (AIUI pre-built dist)
!demo/
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
+1 -6
View File
@@ -17,9 +17,6 @@ on:
branches: [main]
paths:
- 'neode-ui/**'
- 'aiui/**'
- 'scripts/build-aiui.sh'
- '.dockerignore'
- 'docker-compose.demo.yml'
- '.gitea/workflows/demo-images.yml'
workflow_dispatch:
@@ -68,12 +65,10 @@ jobs:
push: true
build-args: |
VITE_DEMO=1
SOURCE_REVISION=${{ github.sha }}
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy
# Source pushes prepare images; public deployment is an explicit post-release action.
if: ${{ success() && github.event_name == 'workflow_dispatch' && secrets.PORTAINER_WEBHOOK != '' }}
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
+1 -6
View File
@@ -17,9 +17,6 @@ on:
branches: [main]
paths:
- 'neode-ui/**'
- 'aiui/**'
- 'scripts/build-aiui.sh'
- '.dockerignore'
- 'docker-compose.demo.yml'
- '.github/workflows/demo-images.yml'
workflow_dispatch:
@@ -68,12 +65,10 @@ jobs:
push: true
build-args: |
VITE_DEMO=1
SOURCE_REVISION=${{ github.sha }}
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy
# Source pushes prepare images; public deployment is an explicit post-release action.
if: ${{ success() && github.event_name == 'workflow_dispatch' && secrets.PORTAINER_WEBHOOK != '' }}
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
-3
View File
@@ -162,6 +162,3 @@ uploads/
# Generated PWA dev output (vite-plugin-pwa) — never a source artifact
neode-ui/dev-dist/
# Isolated feature worktree compilation and validation artifacts
.build/
+24 -42
View File
@@ -1,3 +1,27 @@
# 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
@@ -5,45 +29,3 @@ unrestricted `cargo test` on a node with installed apps: older mocked-runtime
tests still reached real service commands. The runner isolates wallet data,
service buses, container storage, networking, and process IDs. Compilation with
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
## Active release regression checklist
Before resuming release work, read
`docs/post-1.8.22-regressions-20261001.md` and retain its unfinished tasks.
The operator requested that every reported issue be tracked, fixed and tested
before another OTA/ISO. Keep source/unit-test results separate from actual-node
acceptance. In particular, paid-file recovery must not send another payment,
and app cleanup must preserve wallets, persistent data and uninstall decisions.
Do not mark the new paid-file incident resolved merely because the earlier
Framework LND startup incident was closed.
## Gitea and ngit mirror parity
Nostr Git (`ngit`) is the canonical contribution and review platform. Gitea
(`origin`) mirrors accepted code on `main` and release tags. Both are required
publication mirrors; duplicate PRs and proposal branches on Gitea are not required.
For every change, including fixes and release preparation:
- Review and merge once. Push the exact same resulting commits to both mirrors;
never independently squash, rebase or merge the same change on each platform.
- Open new contributions and PRs on ngit; review and merge there, then mirror the
exact accepted main commits to Gitea. Record the ngit proposal and resulting
merge commit in the release ledger. Existing Gitea PRs must be reviewed and
explicitly linked to their ngit replacement or accepted result before closing;
do not abandon contributions or mark unmerged changes as merged. PR numbers,
reviews and discussions remain platform-specific; matching Git refs does not
prove their synchronization.
- Push main and release tags to both mirrors. Preserve commit history
and annotated tag objects/signatures. Do not resolve drift by force pushing,
deleting remote refs, or rewriting published history without explicit approval.
- After publishing source, run `python3 scripts/check-git-mirrors.py --local`.
Include each additional shared branch or release tag with repeated `--ref`
arguments (full `refs/heads/...` or `refs/tags/...` names).
- Before OTA, catalog or ISO publication, require matching reviewed local and
remote main and release tag refs, and record ngit PR dispositions in the
release acceptance ledger. A failed push, unavailable mirror, missing ref or
mismatch blocks publication; never describe a partial push as synchronized.
Run `--all` for a complete advertised branch/tag audit; a main-only pass must
never be described as full historical mirror parity. Proposal-only branches
may intentionally differ. Existing unrelated drift
must be inventoried explicitly rather than silently overwritten.
+2 -5
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 57
versionName = "0.5.37"
versionCode = 52
versionName = "0.5.32"
vectorDrawables {
useSupportLibrary = true
@@ -142,9 +142,6 @@ tasks.matching {
}.configureEach { dependsOn("buildRustArm64") }
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("org.robolectric:robolectric:4.14.1")
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom)
+1 -10
View File
@@ -10,7 +10,6 @@
<!-- 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
@@ -38,13 +37,11 @@
<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|smallestScreenSize|keyboardHidden">
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -68,12 +65,6 @@
</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,18 +9,11 @@ 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.finishKioskActivity
import com.archipelago.app.ui.screens.releaseKioskWebView
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.
@@ -56,8 +49,11 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() {
super.onDestroy()
// Keep an authorized playing WebView owned by the media service;
// discard ordinary dashboard state when the task is finished.
if (isFinishing) finishKioskActivity()
// 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()
}
}
@@ -74,7 +74,6 @@ 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
@@ -253,7 +252,6 @@ 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,
@@ -309,7 +307,6 @@ 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
@@ -454,17 +451,6 @@ 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)
}
@@ -484,7 +470,7 @@ private fun MenuPanel(
}
}
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER, AUDIO }
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER }
/** Big tappable destination card for the hub page: icon + title + subtitle. */
@Composable
@@ -1,194 +0,0 @@
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
}
@@ -1,135 +0,0 @@
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() }
}
@@ -1,47 +0,0 @@
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())
}
}
@@ -1,177 +0,0 @@
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()
@@ -1,179 +0,0 @@
package com.archipelago.app.ui.screens
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.webkit.CookieManager
import android.webkit.DownloadListener
import android.webkit.URLUtil
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.*
import okhttp3.Call
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.io.OutputStream
import java.util.concurrent.TimeUnit
internal data class WebDownload(val url: String, val userAgent: String, val cookies: String, val name: String, val mime: String)
/** Only the starting origin receives its WebView cookies, even across redirects. */
internal fun streamWebDownload(
download: WebDownload,
output: OutputStream,
client: OkHttpClient,
onCall: (Call) -> Unit = {},
checkCancelled: () -> Unit = {},
onProgress: (Long, Long) -> Unit = { _, _ -> },
): Long {
val transport = client.newBuilder().followRedirects(false).followSslRedirects(false).build()
val original = download.url.toHttpUrlOrNull() ?: throw IOException("Unsupported download link")
var url = original
var redirects = 0
while (true) {
checkCancelled()
if (url.username.isNotEmpty() || url.password.isNotEmpty()) throw IOException("Unsupported download link")
val request = Request.Builder().url(url).header("User-Agent", download.userAgent)
if (url.scheme == original.scheme && url.host == original.host && url.port == original.port && download.cookies.isNotBlank()) {
request.header("Cookie", download.cookies)
}
val call = transport.newCall(request.build())
onCall(call)
call.execute().use { response ->
if (response.code in listOf(301, 302, 303, 307, 308)) {
if (++redirects > 5) throw IOException("Too many download redirects")
val next = response.header("Location")?.let { url.resolve(it) } ?: throw IOException("Invalid download redirect")
if (url.isHttps && !next.isHttps) throw IOException("Insecure download redirect blocked")
url = next
} else {
if (response.code == 401 || response.code == 403) throw IOException("Sign in to the node again, then retry the download")
if (!response.isSuccessful) throw IOException("Download failed (HTTP ${response.code})")
if (response.header("Content-Type")?.substringBefore(';')?.trim()?.lowercase() == "text/html" &&
download.mime != "text/html" && !download.name.endsWith(".html", true) && !download.name.endsWith(".htm", true)) {
throw IOException("Sign in to the node again, then retry the download")
}
val body = response.body ?: throw IOException("The download was empty")
val total = body.contentLength()
var written = 0L
body.byteStream().use { input ->
val buffer = ByteArray(64 * 1024)
var lastUpdate = 0L
while (true) {
checkCancelled()
val count = input.read(buffer)
if (count == -1) break
output.write(buffer, 0, count)
written += count
val now = System.nanoTime()
if (now - lastUpdate > 100_000_000L) { onProgress(written, total); lastUpdate = now }
}
}
if (total >= 0 && written != total) throw IOException("Download interrupted; please retry")
onProgress(written, total)
return written
}
}
}
}
/** Uses the system Save dialog: no broad storage permission and no external browser login. */
@Composable
internal fun rememberWebViewDownloads(): DownloadListener {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var pending by remember { mutableStateOf<WebDownload?>(null) }
var active by remember { mutableStateOf<WebDownload?>(null) }
var progress by remember { mutableStateOf<Pair<Long, Long>>(0L to -1L) }
var failure by remember { mutableStateOf<String?>(null) }
var job by remember { mutableStateOf<Job?>(null) }
val currentCall = remember { java.util.concurrent.atomic.AtomicReference<Call?>(null) }
val client = remember {
OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.connectTimeout(20, TimeUnit.SECONDS).readTimeout(60, TimeUnit.SECONDS).build()
}
fun cancel() { job?.cancel(); currentCall.getAndSet(null)?.cancel() }
DisposableEffect(Unit) { onDispose { currentCall.getAndSet(null)?.cancel() } }
val save = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val download = pending
pending = null
val uri = result.data?.data
if (result.resultCode != Activity.RESULT_OK || uri == null || download == null) return@rememberLauncherForActivityResult
job = scope.launch {
active = download
progress = 0L to -1L
var complete = false
try {
withContext(Dispatchers.IO) {
val task = currentCoroutineContext()
context.contentResolver.openOutputStream(uri, "w")?.use { output ->
streamWebDownload(download, output, client,
onCall = { call -> currentCall.set(call); if (!task.isActive) call.cancel() },
checkCancelled = { task.ensureActive() },
onProgress = { done, total -> scope.launch { progress = done to total } })
} ?: throw IOException("Unable to open the selected destination")
}
complete = true
Toast.makeText(context, "Download complete: ${download.name}", Toast.LENGTH_LONG).show()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (currentCoroutineContext().isActive) {
// Do not expose authenticated URLs or request headers in UI/logs.
failure = when {
error is javax.net.ssl.SSLException -> "The server certificate could not be verified."
error is IOException && error.message?.startsWith("Sign in") == true -> error.message
else -> "Download failed. Check your connection and available storage, then try again."
}
}
} finally {
currentCall.getAndSet(null)?.cancel()
if (!complete) withContext(NonCancellable + Dispatchers.IO) {
// This URI was newly created by ACTION_CREATE_DOCUMENT; never remove an existing user file.
runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) }
}
active = null
job = null
}
}
}
if (active != null) {
AlertDialog(onDismissRequest = {}, title = { Text("Downloading") }, text = {
Column {
Text(active!!.name)
if (progress.second > 0) LinearProgressIndicator(progress = (progress.first.toFloat() / progress.second).coerceIn(0f, 1f))
else LinearProgressIndicator()
}
}, confirmButton = {}, dismissButton = { TextButton(onClick = { cancel() }) { Text("Cancel") } })
}
failure?.let { message ->
AlertDialog(onDismissRequest = { failure = null }, title = { Text("Download unavailable") },
text = { Text(message) }, confirmButton = { TextButton(onClick = { failure = null }) { Text("OK") } })
}
return DownloadListener { url, userAgent, disposition, mimeType, _ ->
if (active != null || pending != null) {
Toast.makeText(context, "Finish or cancel the current download first", Toast.LENGTH_SHORT).show()
} else if (url.toHttpUrlOrNull() == null) {
failure = "This download link is not supported. Open the file from Cloud and try again."
} else {
val mime = mimeType?.substringBefore(';')?.takeIf { it.contains('/') } ?: "application/octet-stream"
val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[\\\\/\\p{Cntrl}]"), "_").take(180).ifBlank { "download" }
pending = WebDownload(url, userAgent ?: "Archipelago Companion", CookieManager.getInstance().getCookie(url).orEmpty(), name, mime)
try {
save.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE); type = mime; putExtra(Intent.EXTRA_TITLE, name)
})
} catch (_: Exception) { pending = null; failure = "No file-saving app is available on this device." }
}
}
}
@@ -1,117 +0,0 @@
package com.archipelago.app.ui.screens
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Color
import android.view.View
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
private fun Context.fullscreenActivity(): ComponentActivity? = when (this) {
is ComponentActivity -> this
is ContextWrapper -> baseContext.takeIf { it !== this }?.fullscreenActivity()
else -> null
}
/** Hosts Chromium's custom fullscreen view without replacing or reloading its WebView. */
internal class WebViewFullscreen(private val activity: ComponentActivity?) {
private var overlay: FrameLayout? = null
private var callback: WebChromeClient.CustomViewCallback? = null
private var back: OnBackPressedCallback? = null
val isActive: Boolean get() = overlay != null
fun bounds(rect: android.graphics.Rect): Boolean = overlay?.getGlobalVisibleRect(rect) == true
private var visibleBars = 0
private var originalBehavior = 0
fun show(view: View?, onHidden: WebChromeClient.CustomViewCallback?) {
val owner = activity
// A second enter must not detach the active video or strand its callback.
if (owner == null || owner.isFinishing || owner.isDestroyed || view == null ||
view.parent != null || overlay != null
) {
onHidden?.onCustomViewHidden()
return
}
val decor = owner.window.decorView as? ViewGroup
if (decor == null) { onHidden?.onCustomViewHidden(); return }
val controller = WindowCompat.getInsetsController(owner.window, decor)
val insets = ViewCompat.getRootWindowInsets(decor)
visibleBars = 0
if (insets?.isVisible(WindowInsetsCompat.Type.statusBars()) != false) {
visibleBars = visibleBars or WindowInsetsCompat.Type.statusBars()
}
if (insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) != false) {
visibleBars = visibleBars or WindowInsetsCompat.Type.navigationBars()
}
originalBehavior = controller.systemBarsBehavior
val host = FrameLayout(owner).apply {
setBackgroundColor(Color.BLACK)
keepScreenOn = true
addView(view, FrameLayout.LayoutParams(-1, -1))
}
overlay = host
callback = onHidden
decor.addView(host, ViewGroup.LayoutParams(-1, -1))
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
back = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = hide()
}.also { owner.onBackPressedDispatcher.addCallback(it) }
view.requestFocus()
}
fun hide() {
val host = overlay ?: return
if (activity?.isInPictureInPictureMode == true) {
// A navigation/logout/custom-view exit must never expose the node
// management UI in the small OS window. Keep a black cover until
// the activity leaves PiP; the ordinary hide then removes it.
val notify = callback; callback = null
back?.remove(); back = null
host.keepScreenOn = false; host.removeAllViews()
host.addView(android.widget.TextView(host.context).apply {
text = "Video paused. Expand to return."
setTextColor(Color.WHITE)
gravity = android.view.Gravity.CENTER
contentDescription = "Video paused. Use picture-in-picture controls to expand or close."
}, FrameLayout.LayoutParams(-1, -1))
notify?.onCustomViewHidden()
return
}
// Clear first: Chromium may synchronously call onHideCustomView again.
overlay = null
val notify = callback
callback = null
back?.remove()
back = null
host.keepScreenOn = false
host.removeAllViews()
(host.parent as? ViewGroup)?.removeView(host)
activity?.let { owner ->
val controller = WindowCompat.getInsetsController(owner.window, owner.window.decorView)
controller.systemBarsBehavior = originalBehavior
controller.hide(WindowInsetsCompat.Type.systemBars())
if (visibleBars != 0) controller.show(visibleBars)
}
notify?.onCustomViewHidden()
}
}
@Composable
internal fun rememberWebViewFullscreen(): WebViewFullscreen {
val context = LocalContext.current
val fullscreen = remember(context) { WebViewFullscreen(context.fullscreenActivity()) }
DisposableEffect(fullscreen) { onDispose { fullscreen.hide() } }
return fullscreen
}
@@ -144,29 +144,6 @@ 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. */
@@ -317,7 +294,6 @@ 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
@@ -330,19 +306,13 @@ private object KioskWebView {
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
var onQrClose: () -> Unit = {}
fun clearDelegates() {
onRouteOutbound = {}; onOpenInApp = {}; onQrOpen = {}
onQrStatus = { _, _ -> }; onQrClose = {}
}
fun drop() {
val old = instance
instance = null; url = null; backgroundOwned = false
clearDelegates()
old?.let {
CompanionAudioBridge.release(it)
instance?.let {
(it.parent as? ViewGroup)?.removeView(it)
it.destroy()
}
instance = null
url = null
}
}
@@ -641,9 +611,6 @@ fun WebViewScreen(
// before surfacing the error page: the mesh tunnel works from anywhere.
meshFallbackUrl: String? = null,
) {
val fullscreen = rememberWebViewFullscreen()
val cloudPip = rememberCloudVideoPip(fullscreen)
val downloads = rememberWebViewDownloads()
var isLoading by remember { mutableStateOf(true) }
// First kiosk load (often over the FIPS mesh) gets the full branded
// loader; later navigations keep just the slim top progress bar.
@@ -931,9 +898,7 @@ fun WebViewScreen(
// stale closures from the previous visit are replaced.
if (KioskWebView.url != serverUrl) KioskWebView.drop()
val reused = KioskWebView.instance
(reused ?: WebView(android.content.MutableContextWrapper(context))).apply {
(this.context as? android.content.MutableContextWrapper)?.baseContext = context
KioskWebView.backgroundOwned = false
(reused ?: WebView(context)).apply {
(parent as? ViewGroup)?.removeView(this)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -948,9 +913,6 @@ fun WebViewScreen(
cookieManager.setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
CompanionAudioBridge.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
setDownloadListener(downloads)
settings.apply {
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
// Let JS open windows without a synchronous user-gesture
@@ -1124,8 +1086,6 @@ 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
@@ -1221,12 +1181,6 @@ fun WebViewScreen(
}
webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
fullscreen.show(view, callback)
}
override fun onHideCustomView() { cloudPip.reset(); fullscreen.hide() }
override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadProgress = newProgress
}
@@ -1592,8 +1546,6 @@ private fun InAppBrowser(
appName: String? = null,
onClose: () -> Unit,
) {
val fullscreen = rememberWebViewFullscreen()
val downloads = rememberWebViewDownloads()
val context = LocalContext.current
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
@@ -1691,7 +1643,6 @@ private fun InAppBrowser(
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
setDownloadListener(downloads)
// Node apps (BTCPay invoices, LND, Portainer tokens) are
// served over plain HTTP too — same dead-clipboard trap.
addClipboardBridge()
@@ -1711,12 +1662,6 @@ private fun InAppBrowser(
)
webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
fullscreen.show(view, callback)
}
override fun onHideCustomView() = fullscreen.hide()
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progress = newProgress
}
@@ -1,26 +0,0 @@
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))
}
}
@@ -1,157 +0,0 @@
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 }
}
@@ -1,88 +0,0 @@
package com.archipelago.app.ui.screens
import okhttp3.OkHttpClient
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.Assert.*
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.concurrent.CancellationException
class WebViewDownloadsTest {
private fun spec(url: String) = WebDownload(url, "test-agent", "session=test-only", "file.bin", "application/octet-stream")
@Test fun authenticatedDownloadWritesExactBytesAndReportsCompletion() {
MockWebServer().use { server ->
val bytes = ByteArray(256 * 1024 + 13) { (it % 251).toByte() }
server.enqueue(MockResponse().setBody(Buffer().write(bytes)))
val out = ByteArrayOutputStream()
var progress = 0L to 0L
assertEquals(bytes.size.toLong(), streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient(), onProgress = { done, total -> progress = done to total }))
assertArrayEquals(bytes, out.toByteArray())
assertEquals(bytes.size.toLong() to bytes.size.toLong(), progress)
assertEquals("session=test-only", server.takeRequest().getHeader("Cookie"))
}
}
@Test fun sameOriginRedirectKeepsSessionButCrossOriginNeverReceivesIt() {
MockWebServer().use { first -> MockWebServer().use { second ->
second.enqueue(MockResponse().setBody("final"))
first.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/relative"))
first.enqueue(MockResponse().setResponseCode(307).addHeader("Location", second.url("/target")))
val out = ByteArrayOutputStream()
streamWebDownload(spec(first.url("/start").toString()), out, OkHttpClient())
assertEquals("final", out.toString())
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
assertNull(second.takeRequest().getHeader("Cookie"))
} }
}
@Test fun authenticationFailureDoesNotSaveErrorBody() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setResponseCode(401).setBody("login required"))
val out = ByteArrayOutputStream()
val error = assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient()) }
assertTrue(error.message!!.startsWith("Sign in"))
assertEquals(0, out.size())
}
}
@Test fun redirectsAreBoundedAndUnsafeSchemesAreRejected() {
MockWebServer().use { server ->
repeat(6) { server.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/loop")) }
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/loop").toString()), ByteArrayOutputStream(), OkHttpClient()) }
assertEquals(6, server.requestCount)
}
for (url in listOf("file:///etc/passwd", "data:text/plain,test", "blob:test")) {
assertThrows(IOException::class.java) { streamWebDownload(spec(url), ByteArrayOutputStream(), OkHttpClient()) }
}
}
@Test fun cancellationAndDestinationFailureAreNotReportedAsComplete() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody("bytes"))
assertThrows(CancellationException::class.java) { streamWebDownload(spec(server.url("/").toString()), ByteArrayOutputStream(), OkHttpClient(), checkCancelled = { throw CancellationException() }) }
assertEquals(0, server.requestCount)
var progressCalled = false
val out = object : java.io.OutputStream() { override fun write(b: Int) { throw IOException("disk full") } }
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient(), onProgress = { _, _ -> progressCalled = true }) }
assertFalse(progressCalled)
}
}
@Test fun tlsDowngradeAndLoginHtmlAreRejected() {
var requests = 0
val client = OkHttpClient.Builder().addInterceptor { chain ->
requests++
okhttp3.Response.Builder().request(chain.request()).protocol(okhttp3.Protocol.HTTP_1_1)
.code(302).message("redirect").header("Location", "http://example.test/file").body("".toResponseBody(null)).build()
}.build()
assertThrows(IOException::class.java) { streamWebDownload(spec("https://example.test/file"), ByteArrayOutputStream(), client) }
assertEquals(1, requests)
MockWebServer().use { server ->
server.enqueue(MockResponse().addHeader("Content-Type", "Text/HTML; charset=utf-8").setBody("<html>Sign in</html>"))
val out = ByteArrayOutputStream()
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient()) }
assertEquals(0, out.size())
}
}
}
@@ -1,90 +0,0 @@
package com.archipelago.app.ui.screens
import android.view.View
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [28, 35])
class WebViewFullscreenTest {
@Test fun closingVideoInPipKeepsOpaqueCoverUntilActivityReturns() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val video = View(activity)
var hidden = 0
fullscreen.show(video) { hidden++ }
assertTrue(activity.enterPictureInPictureMode(android.app.PictureInPictureParams.Builder().build()))
assertTrue(activity.isInPictureInPictureMode)
fullscreen.hide()
assertNull(video.parent)
assertTrue(fullscreen.isActive)
assertEquals(1, hidden)
fullscreen.hide()
assertEquals(1, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun backExitsFullscreenWithoutFinishingActivityAndNotifiesOnce() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val video = View(activity)
var hidden = 0
fullscreen.show(video) { hidden++ }
assertNotNull(video.parent)
activity.onBackPressedDispatcher.onBackPressed()
assertNull(video.parent)
assertFalse(activity.isFinishing)
assertEquals(1, hidden)
fullscreen.hide()
assertEquals(1, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun duplicateRequestPreservesActiveViewAndCanReenterAfterExit() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val first = View(activity)
val second = View(activity)
var firstHidden = 0
var secondHidden = 0
fullscreen.show(first) { firstHidden++ }
fullscreen.show(second) { secondHidden++ }
assertNotNull(first.parent)
assertNull(second.parent)
assertEquals(0, firstHidden)
assertEquals(1, secondHidden)
fullscreen.hide()
fullscreen.show(second) { secondHidden++ }
assertNotNull(second.parent)
fullscreen.hide()
assertEquals(1, firstHidden)
assertEquals(2, secondHidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun rejectsOwnedViewWithoutReparentingAndHandlesUnavailableActivity() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val video = View(activity)
val owner = FrameLayout(activity).apply { addView(video) }
var hidden = 0
WebViewFullscreen(activity).show(video) { hidden++ }
assertSame(owner, video.parent)
WebViewFullscreen(null).show(null) { hidden++ }
assertEquals(2, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
}
-31
View File
@@ -1,38 +1,7 @@
# Changelog
## v1.9.0-alpha (2026-10-05)
Unpublished release candidate; qualification is still in progress.
- Keep Cuprate and NetBird supporting components out of app listings and consolidate BTCPay Server under Commerce.
- Default on-chain sends, channel opens and cooperative closes to a dynamic next-block fee target, preserving explicit slower and custom choices.
- Add reviewed fee-bump quotes, explicit budgets and durable operation tracking for supported wallet transactions.
- Preserve Nginx Proxy Manager storage, same-node upstream connectivity, certificates and access controls through managed migrations.
- Restrict public management access while retaining configured public apps and ACME certificate validation.
- Serve the Mempool explorer on the Angor indexer origin alongside its API.
- Include the self-contained LoRa flashing tool and explicit board selection in update and installer payloads.
- Preserve paid-file Lightning entitlements across restarts and recover settled invoices from LND. Retry delivery without paying again and retain purchased files in the owned cache.
- Return explicit payment-status errors with safe retry guidance when verification is unavailable.
- Keep upload progress on its original screen, show completion there or notify on other screens, and cancel active and queued uploads.
- Resume interrupted uploads while the app remains open, preserve the original destination, and verify saved file contents before reporting completion.
- Provision a unique private File Browser login on each node while keeping Cloud sign-in automatic and preserving existing accounts and files.
- Update Nostr dependencies to reject forged relay events and oversized encrypted messages; preserve native signing and encryption compatibility.
- Allow apps to opt in to a validated public-key list of user identities without granting signing access.
- Make transaction filters transparent and horizontally scrollable on mobile.
- Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps.
- Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.
- Offer Standard, Medium, Fast and custom fees when cooperatively closing Lightning channels.
- Add a clear-search icon to My Apps, Services and the App Store on desktop and mobile.
- Keep Angor Indexer and the optional Angor Relay in the signed app catalog. Full indexing requires a synced, unpruned Bitcoin node and its indexing dependencies.
## v1.8.22-alpha (2026-09-30)
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
- Network diagnostic failures no longer stop all apps or rebuild shared container networking.
- Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.
- Fixed companion dashboard builds still referencing a retired image registry.
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
+1 -38
View File
@@ -64,49 +64,12 @@ App submissions must:
## Pull requests
Contributions, PRs and reviews live on **ngit**. Clone the canonical repository:
```text
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
```
Gitea is a conventional Git mirror of accepted `main` commits and release tags.
You do not need to open a duplicate Gitea PR. Existing Gitea contributions will
be reviewed and linked to their ngit replacement or accepted result before
closure.
1. Open one focused ngit PR per behavior or documentation change.
1. Open one focused PR per behavior or documentation change.
2. Explain what changed, why it changed, and how it was verified.
3. Include screenshots for UI changes.
4. Link relevant issues or docs.
5. Keep generated catalog changes in sync with manifest changes.
### Maintainer publication gate
Merge once through the ngit contribution workflow, then push the exact same
accepted commits to Gitea. Do not independently merge or squash on each mirror.
Publish identical release tag objects, including annotations and signatures.
After pushing main, verify:
```bash
python3 scripts/check-git-mirrors.py --local
```
Before publishing release artifacts, also check the actual release tag:
```bash
python3 scripts/check-git-mirrors.py --local --ref refs/tags/v1.9.0-alpha
```
Use the release's actual tag name. Missing refs, inaccessible mirrors or differing
object IDs block publication. Record the ngit PR disposition and resulting merge
commit in the release acceptance ledger. Resolve drift deliberately; do not
force-push or delete published history without explicit approval.
The checker is read-only. `--all` audits every advertised branch and tag; ngit
proposal branches may intentionally differ from Gitea. A main-only pass proves
only main parity, and no Git ref check verifies PR discussions or review state.
Suggested commit format:
```text
+5 -56
View File
@@ -1,7 +1,5 @@
# Archipelago
> **Alpha testing — funds at your own risk.** Archipelago is experimental software and is not production-ready. Any funds you put on it are at your own risk. Substantial security hardening is planned before production readiness; current builds are for testing and feedback.
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
@@ -13,19 +11,14 @@ Podman containers managed by the Rust backend.
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/)
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.9.0--alpha-blue)](https://source.archipelago-foundation.org/lfg2025/archy/releases)
[![Version](https://img.shields.io/badge/version-1.8.13--alpha-blue)](https://source.archipelago-foundation.org/lfg2025/archy/releases)
## Current release
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:
The current pre-release is **v1.8.13-alpha**. Release notes and signed OTA
artifacts are published on [Gitea](https://source.archipelago-foundation.org/lfg2025/archy/releases).
The same source is mirrored through ngit for Nostr-native cloning and
contribution:
```
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
@@ -34,50 +27,6 @@ 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
@@ -1,28 +0,0 @@
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()
})
})
+5 -24
View File
@@ -61,12 +61,6 @@ 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())
@@ -80,6 +74,11 @@ 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')
@@ -250,24 +249,6 @@ 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,7 +123,6 @@
: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 }}
@@ -248,7 +247,6 @@ 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<{
@@ -334,7 +332,8 @@ const modelDisplayName = computed(() => {
})
function selectModel(providerId: string, modelId: string) {
if (setProvider(providerId as Parameters<typeof setProvider>[0])) setModel(modelId)
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock')
setModel(modelId)
showModelPicker.value = false
}
@@ -186,9 +186,8 @@ defineEmits<{
}>()
const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey, needsFunding } = useAI()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = 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()
@@ -207,19 +206,11 @@ const showSettings = ref(false)
// without fixing anything).
watch(needsApiKey, (needs) => {
if (needs) {
if (archyBridge.isInArchy()) archyBridge.requestAISetup()
else showSettings.value = true
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,9 +10,6 @@
>
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"
@@ -22,8 +19,6 @@
</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>
@@ -58,7 +53,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { archyBridge } from '@/services/archyBridge'
const props = defineProps<{
code: string
@@ -70,18 +64,6 @@ 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,9 +1,5 @@
<template>
<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">
<div class="space-y-4">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
API Keys
</h3>
@@ -97,12 +93,10 @@
</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 {
@@ -162,5 +156,5 @@ async function removeKey(provider: string) {
await loadProviders()
}
onMounted(() => { if (!embedded) void loadProviders() })
onMounted(loadProviders)
</script>
+29 -32
View File
@@ -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' | 'openai' | 'auto' | 'local'
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock'
// 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,15 +120,34 @@ 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>('routstr')
const activeProvider = ref<Provider>('claude')
const activeModel = ref('routstr-unavailable')
const activeModel = ref('claude-haiku-4.5')
// Credentials require setup; network failures and provider outages require retry.
// 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).
const needsApiKey = ref(false)
const needsFunding = ref(false)
function looksLikeMissingApiKey(err: string): boolean {
return /\b(401|403)\b|api[ _-]?key|unauthorized|authentication_error|credential/i.test(err)
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')
)
}
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
@@ -142,7 +161,7 @@ async function refreshRoutstrModels() {
routstrModelsFetched = true
try {
const res = await apiFetch(ROUTSTR_MODELS_PATH)
if (!res.ok) { routstrModelsFetched = false; return }
if (!res.ok) return
const data = await res.json()
if (Array.isArray(data?.data)) {
routstrModels.value = data.data
@@ -151,21 +170,13 @@ 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',
@@ -176,7 +187,7 @@ const availableProviders = computed(() => {
},
{
id: 'claude',
name: 'Claude API',
name: 'Claude (Max)',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
@@ -196,36 +207,24 @@ const availableProviders = computed(() => {
})
providers.push({
id: 'mock',
name: 'Demo echo',
name: 'Local (no API)',
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
@@ -449,7 +448,6 @@ 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.
@@ -976,6 +974,5 @@ export function useAI() {
setProvider,
setModel,
needsApiKey,
needsFunding,
}
}
+1 -22
View File
@@ -55,10 +55,6 @@ 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
@@ -84,19 +80,12 @@ function postToParent(msg: unknown) {
function handleMessage(event: MessageEvent) {
// Always validate origin — reject if not configured or mismatched
if (!allowedOrigin || event.origin !== allowedOrigin || event.source !== window.parent) return
if (!allowedOrigin || event.origin !== allowedOrigin) 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) {
@@ -234,21 +223,11 @@ 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 */
+6 -73
View File
@@ -436,13 +436,13 @@
{
"id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager",
"version": "2.14.0",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. The node's public web server forwards configured domains through this service, preserving its access lists, certificates and custom routes.",
"version": "2.12.1",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
},
{
@@ -648,9 +648,9 @@
{
"id": "angor-indexer",
"title": "Angor Indexer",
"version": "1.0.2",
"description": "Bitcoin indexer endpoint for Angor with the existing Mempool explorer. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
"version": "1.0.1",
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
"author": "Angor / Archipelago",
"requires": [
"Mempool API",
@@ -673,73 +673,6 @@
"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"
}
]
}
-2
View File
@@ -91,5 +91,3 @@ 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.
+3 -55
View File
@@ -1,8 +1,7 @@
# Angor Indexer
Mainnet indexer endpoint for Angor, serving the existing Mempool explorer at
the same origin. The service reuses this node's Mempool frontend/backend and
Electrum index instead of creating another explorer or blockchain database.
Headless mainnet API endpoint for Angor. The service reuses this node's Mempool
backend and Electrum index instead of creating a second blockchain database.
An unpruned, fully synced Bitcoin node is required. Installing against a pruned
node must show the existing archival-node requirement; it must never silently
unprune or replace its Bitcoin data.
@@ -33,45 +32,12 @@ Install **Angor Relay** separately to host project metadata locally, then add
clients. Its storage and configuration are separate from the node's internal
relay; installing or uninstalling it does not change the internal relay.
## Verify the complete client flow
The root URL opens the Mempool explorer. `/health` and fee
estimates establish API availability; they do not prove that project discovery,
address history, or browser CORS works. Test a known funded project's address
history, its original Nostr announcement, the Explore page, and project details
in the actual Angor client. A certificate alone does not establish public routing.
Keep existing discovery relays when adding a new relay. A new relay has no
historical project data and does not automatically replicate other relays.
Even with existing relays, an empty Explore page can be a client discovery
failure: Angor Hub v2.0.0 was observed to stop after a batch whose announcements
all failed on-chain validation. The same failure reproduced with our indexer
and Angor's public indexer. Do not bypass the funding transaction's event-ID
commitment or substitute an unsigned announcement to make a project appear.
For opt-in read-only browser acceptance, install the frontend test dependencies
and Playwright Chromium, then run:
```sh
ANGOR_TEST_INDEXER=https://indexer.example.com/ \
ANGOR_TEST_RELAY=wss://relay.example.com/ \
ANGOR_TEST_RELAYS='["wss://relay.angor.io","wss://relay.example.com/"]' \
node tests/lifecycle/angor-public-browser.cjs
```
The relay under test must already contain the known original public project
announcement documented in the test. The test does not import events, send
funds, change your browser profile, or disable TLS verification. It checks the
funding transaction/event commitment and real browser discovery and details.
Relay signed writes, invalid-signature rejection, persistence, full node sync,
and proxy upgrade/renewal tests remain separate acceptance requirements.
## Packaging
Build the pinned image with:
```
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.2 apps/angor-indexer/container
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.1 apps/angor-indexer/container
```
The image runs as UID 101 with a read-only root filesystem and no capabilities.
@@ -89,21 +55,3 @@ address query is indexed at the latest Bitcoin tip.
Install Mempool Explorer first. The declarative `install_prerequisites` check
refuses a new adapter installation if its Mempool API component is absent, before
creating an installed-app record. It does not install or resync Bitcoin for you.
## Explorer on the public indexer origin
The linked official deployment guide exposes **Mempool frontend and API together**
on the public indexer URL. It uses standard Mempool images and requires no custom
Angor fork or `ANGOR_ENABLED` flag.
The operator now requires that same browser experience: opening the configured
indexer domain must show the existing Mempool explorer, while Angor API requests
continue working on that origin. Reuse the existing Mempool stack, including its
live WebSocket feed; do not install a second explorer or blockchain database.
**Candidate 1.0.2:** `/` and frontend paths proxy to the existing Mempool
frontend; `/api/`, `/api/v1/`, `/health` and the WebSocket feed retain their
indexer routes. Version 1.0.1 served only service JSON at `/`. The candidate
remains pending deployment/release acceptance, which must cover assets and deep links,
desktop/mobile rendering, WebSocket updates, API/CORS/broadcast, trusted HTTPS,
restart/upgrade and management-access isolation before documenting it as shipped.
+6 -24
View File
@@ -15,14 +15,6 @@ http {
zone mempool_backend 64k;
server mempool-api:8999 resolve;
}
upstream mempool_frontend {
zone mempool_frontend 64k;
server mempool:8080 resolve;
}
map $http_upgrade $angor_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 8080;
client_max_body_size 4m;
@@ -31,8 +23,7 @@ http {
proxy_send_timeout 30s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection $angor_connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "";
proxy_set_header Authorization "";
proxy_set_header Cookie "";
proxy_hide_header Access-Control-Allow-Origin;
@@ -44,6 +35,10 @@ http {
# Mempool's backend uses /api/v1. Match its frontend's shorter /api
# surface too, without doubling already-versioned Angor URLs.
rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last;
location = / {
default_type application/json;
return 200 '{"service":"Angor Indexer","network":"mainnet","api":"/api/v1","health":"/health"}\n';
}
# Readiness checks the indexing backend, not this gateway's process.
location = /health {
limit_except GET { deny all; }
@@ -59,23 +54,10 @@ http {
limit_except GET POST { deny all; }
proxy_pass http://mempool_backend;
}
location = /api/v1/ws {
limit_except GET { deny all; }
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_pass http://mempool_backend;
}
location /api/ {
limit_except GET { deny all; }
proxy_pass http://mempool_backend;
}
# Share the already-installed explorer; no second frontend or index DB.
# Its SPA handles transaction/block deep links and static assets.
location / {
limit_except GET { deny all; }
proxy_pass http://mempool_frontend;
proxy_intercept_errors on;
error_page 500 502 503 504 =503 @waiting;
}
location / { return 404; }
}
}
+5 -10
View File
@@ -1,26 +1,22 @@
app:
id: angor-indexer
name: Angor Indexer
version: 1.0.2
description: Bitcoin indexer endpoint for Angor with the existing Mempool explorer.
Reuses this node’s Mempool
version: 1.0.1
description: Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool
and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s
address as the custom indexer in Angor settings. A relay is optional and installed
separately.
category: money
install_prerequisites:
- mempool
- mempool-api
upstream:
kind: github
repo: block-core/angor
container:
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.2
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.1
pull_policy: if-not-present
network: archy-net
dependencies:
- app_id: mempool
version: '>=3.0.0'
- app_id: mempool-api
version: '>=3.0.0'
- bitcoin:archival
@@ -44,8 +40,8 @@ app:
interfaces:
main:
name: Angor Indexer API
description: Use this origin as Angor’s custom mainnet indexer URL, or open it
to view the existing Mempool explorer. HTTPS is required for browser clients.
description: Use this origin as Angor’s custom mainnet indexer URL. HTTPS is
required for browser clients.
type: api
port: 8998
protocol: http
@@ -67,7 +63,6 @@ app:
repo: https://github.com/block-core/angor
features:
- Angor mainnet API
- Mempool explorer on the same origin
- Reuses existing Mempool indexing
- No separate blockchain database
- Optional independent relay
-12
View File
@@ -8,18 +8,6 @@ this is a public relay, not a private messaging archive. It mounts only
`/var/lib/archipelago/angor-relay` and its separate configuration directory.
It never opens, reconfigures or shares the node's internal strfry database.
For a public domain, proxy HTTPS to node port **8091**, enable WebSocket upgrade,
and add `wss://your-relay-domain/` in Angor. Test both NIP-11 metadata (send
`Accept: application/nostr+json`) and a real Nostr subscription over WSS. An
Archipelago login page at this domain is a routing failure, not relay readiness.
New relays start without project history. Keep existing discovery relays alongside
yours until the needed original signed announcements and metadata are available
locally. Relays do not automatically synchronize. Any history import must retain
the original event IDs and signatures; verify funded projects against their
on-chain commitments. A working WebSocket with zero stored events is not proof
that the client's project discovery works. See the indexer README's browser test.
The configuration is seeded only when absent, preserving operator changes.
Stop the service before making a consistent backup of its event database.
Ordinary start/restart/recreation preserves both mounts. Use the standard app
-102
View File
@@ -1,102 +0,0 @@
# 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.
-87
View File
@@ -1,87 +0,0 @@
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
-86
View File
@@ -1,86 +0,0 @@
# 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.
-84
View File
@@ -1,84 +0,0 @@
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
-129
View File
@@ -1,129 +0,0 @@
# 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.
-90
View File
@@ -1,90 +0,0 @@
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
-1
View File
@@ -67,7 +67,6 @@ 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
-1
View File
@@ -84,6 +84,5 @@ app:
path: /
metadata:
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
launch:
open_in_new_tab: true
-2
View File
@@ -15,8 +15,6 @@ 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
+4 -6
View File
@@ -69,12 +69,10 @@ 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", "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: ["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: ["nginx", "-s", "reload"]
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
-1
View File
@@ -63,7 +63,6 @@ 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
-62
View File
@@ -1,62 +0,0 @@
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
-1
View File
@@ -59,7 +59,6 @@ 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
+6 -20
View File
@@ -1,23 +1,20 @@
app:
id: nginx-proxy-manager
name: Nginx Proxy Manager
version: 2.14.0
version: 2.12.1
upstream:
kind: github
repo: NginxProxyManager/nginx-proxy-manager
description: >-
Reverse proxy with SSL. Beautiful web interface for managing proxies.
The node's public web server forwards configured domains through this
service, preserving its access lists, certificates and custom routes.
backup_before_runtime_change: true
On a node, this manages its admin UI and upstream configuration — the
proxy's own :80/:443 listeners are not published (the node's web server
owns those ports).
container:
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest
pull_policy: if-not-present
# Rootless pasta copies the LAN IP, preventing requests back to this node.
# Retain the old pasta host gateway used by saved NPM upstreams, plus
# host.containers.internal. This subnet stays inside the private rootless namespace.
network: slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24
network: pasta
dependencies:
- storage: 1Gi
@@ -52,17 +49,6 @@ app:
Nginx Proxy Manager enforces its own admin account on every page;
the initial setup wizard also has to answer before any account exists.
- host: 8088
container: 80
protocol: tcp
bind: 127.0.0.1
auth: local
- host: 8444
container: 443
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
- type: bind
source: /var/lib/archipelago/nginx-proxy-manager
+89
View File
@@ -0,0 +1,89 @@
app:
id: nostr-vpn-web
name: Nostr VPN Control Panel
version: 1.0.0
upstream:
kind: github
repo: mmalmi/nostr-vpn
description: |
Web control panel for the nostr-vpn paid-exit seller (apps/nostr-vpn).
Talks to the daemon only through the shared /data volume (state-file
status + shelling out to the nvpn CLI) -- no network link between the
two containers, mirroring upstream's own umbrel/docker-compose.yml
exactly (read directly, not assumed). Same image as apps/nostr-vpn,
different entrypoint args.
category: money
container:
build:
context: /opt/archipelago/docker/nostr-vpn
dockerfile: Dockerfile
tag: localhost/nostr-vpn:local
entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"]
custom_args:
- /usr/local/bin/nvpn-web
- --listen
- 0.0.0.0:38080
- --behind-trusted-proxy
- --config
- /data/config/nvpn/config.toml
dependencies:
- app_id: nostr-vpn
resources:
memory_limit: 128Mi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: bridge
ports:
- host: 38080
container: 38080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
# Same volume as apps/nostr-vpn, read-write: the panel's wallet/seller
# actions (wallet send, paid-exit run) shell out to the nvpn CLI
# against this same config.toml and data dir, per
# NVPN_EXTERNAL_DAEMON/NVPN_DAEMON_STATUS_MODE below.
- type: bind
source: /var/lib/archipelago/nostr-vpn
target: /data
options: [rw]
environment:
- NVPN_CLI_PATH=/usr/local/bin/nvpn
- NVPN_DAEMON_STATUS_MODE=state-file
- NVPN_EXTERNAL_DAEMON=true
health_check:
type: http
endpoint: http://127.0.0.1:38080
path: /
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Control Panel
description: nostr-vpn paid-exit status, wallet, and seller settings
type: ui
port: 38080
protocol: http
path: /
metadata:
category: money
tier: optional
author: mmalmi
repo: https://github.com/mmalmi/nostr-vpn
features:
- Paid-exit seller status, wallet, and offer controls
- Shares state with apps/nostr-vpn via one data volume, no RPC link
+119
View File
@@ -0,0 +1,119 @@
app:
id: nostr-vpn
name: Nostr VPN (paid exit)
version: 1.0.0
# Pinned commit, not a tag -- upstream has no release tags yet. Re-pin
# deliberately in docker/nostr-vpn/Dockerfile's NVPN_COMMIT build arg; see
# docs/nostr-vpn-integration-plan.md for the Phase 0 feasibility log this
# pin was verified against.
upstream:
kind: github
repo: mmalmi/nostr-vpn
description: |
Sells spare bandwidth as a Nostr-discovered, Cashu-metered paid exit
(github.com/mmalmi/nostr-vpn). Runs rootless in its own network
namespace (pasta) -- NET_ADMIN/NET_RAW are scoped to that netns, never
the host. Seller mode defaults OFF (upstream's own `paid_exit.enabled`
default); turning it on is a separate step (Phase 3 UI, not yet built).
This replaces the old root-mode integration (image-recipe's
nostr-vpn.service running `nvpn daemon` as root, auto-enabled on first
login via rpc/auth.rs) that broke the rootless/no-OS-reliance
invariant. That old path and its RPC TOML-rewriting code
(rpc/vpn.rs::handle_vpn_add_participant) are a separate, higher-risk
removal -- not done here, since it's wired into every node's login
flow today, not just this app.
category: money
container:
build:
context: /opt/archipelago/docker/nostr-vpn
dockerfile: Dockerfile
tag: localhost/nostr-vpn:local
network: pasta
# Image has no image-level ENTRYPOINT/CMD (see Dockerfile) -- both this
# app and nostr-vpn-web point the shared seed-config entrypoint at
# different binaries/args.
entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"]
custom_args:
- /usr/local/bin/nvpn
- daemon
- --config
- /data/config/nvpn/config.toml
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
security:
# NET_ADMIN/NET_RAW: TUN device + the exit forwarding/NAT nvpn installs
# itself inside its own netns (nvpn-exit-forward-in/out, nvpn-exit-masq,
# the MSS clamp) -- confirmed working rootless in Phase 0 testing, with
# no capabilities beyond these two plus the sysctl below. Host iptables
# and routes were confirmed untouched.
capabilities: [NET_ADMIN, NET_RAW]
# false: not verified read-only-root-compatible in Phase 0 testing (the
# working run flags there didn't include --read-only). nvpn's own state
# (config/identity/wallet) lives on the /data volume either way.
readonly_root: false
no_new_privileges: true
network_policy: isolated
# Rootless /proc/sys is read-only, so forwarding can only be set at
# container-create time via this primitive (added for exactly this app --
# see commit e42bd26). nvpn only *reads* ip_forward and writes it when 0,
# so setting it here once at create is enough; nvpn's own cleanup path
# leaves it alone.
sysctls:
net.ipv4.ip_forward: "1"
devices:
- /dev/net/tun
ports:
# Paid-exit buyers dial this directly from the open internet to pay for
# bandwidth -- it's the whole point of the app, not an admin surface,
# and it speaks nvpn's own FIPS UDP wire protocol, not HTTP, so the app
# gate cannot front it. 51822, not upstream's default 51820: that
# collides with archipelago-wg (kernel WireGuard) on fleet nodes --
# found running both side by side in Phase 0 testing.
- host: 51822
container: 51822
protocol: udp
auth: none
auth_rationale: >-
FIPS UDP transport for paid-exit buyers. Anonymous by design (not
HTTP), and the seller is off by default (paid_exit.enabled=false)
until an operator explicitly turns on selling, so exposure here
alone grants no access to anything.
volumes:
# Adopts whatever a node already has under the old root-mode path
# (nostr-vpn.service wrote here too) -- an identity, wallet balance, or
# pending Cashu credit must survive this migration, not reset.
- type: bind
source: /var/lib/archipelago/nostr-vpn
target: /data
options: [rw]
environment:
- NVPN_LISTEN_PORT=51822
health_check:
type: exec
endpoint: nvpn status
interval: 30s
timeout: 10s
retries: 3
metadata:
category: money
tier: optional
author: mmalmi
repo: https://github.com/mmalmi/nostr-vpn
features:
- Sell spare bandwidth as a Cashu-metered Nostr paid exit
- Rootless: own network namespace, no host network access
- Seller mode off by default
-1
View File
@@ -60,7 +60,6 @@ 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
-52
View File
@@ -1,52 +0,0 @@
# 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.
-49
View File
@@ -1,49 +0,0 @@
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]
-3
View File
@@ -219,6 +219,3 @@ app:
nostr_integration:
relay_type: public
monetization_enabled: true
metadata:
guest_access: true
+5 -80
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.9.0-alpha"
version = "1.8.22-alpha"
dependencies = [
"anyhow",
"archipelago-container",
@@ -137,11 +137,9 @@ dependencies = [
"hyper 0.14.32",
"hyper-util",
"hyper-ws-listener",
"image",
"iroh",
"iroh-blobs",
"libc",
"lightning-invoice",
"lofty",
"mainline",
"mdns-sd",
@@ -230,22 +228,6 @@ 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"
@@ -1585,15 +1567,6 @@ 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"
@@ -2530,22 +2503,8 @@ 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]]
@@ -3677,9 +3636,9 @@ dependencies = [
[[package]]
name = "nostr"
version = "0.44.7"
version = "0.44.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9"
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
dependencies = [
"aes",
"base64 0.22.1",
@@ -3722,9 +3681,9 @@ dependencies = [
[[package]]
name = "nostr-relay-pool"
version = "0.44.3"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1"
checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b"
dependencies = [
"async-utility",
"async-wsocket",
@@ -4183,19 +4142,6 @@ 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"
@@ -4420,12 +4366,6 @@ 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"
@@ -7382,18 +7322,3 @@ 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",
]
-5
View File
@@ -7,7 +7,6 @@ members = [
"openwrt",
"performance",
"security",
"publishing-tests",
]
# Shared package metadata, inherited by each member via `license.workspace = true`.
@@ -28,7 +27,3 @@ 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
+2 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.9.0-alpha"
version = "1.8.22-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -73,7 +73,6 @@ 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
@@ -107,8 +106,6 @@ 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"] }
@@ -149,7 +146,6 @@ 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"
@@ -1,142 +0,0 @@
//! 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))?)
}
}
+90 -334
View File
@@ -7,48 +7,14 @@ use hyper::{Response, StatusCode};
use super::{is_valid_app_id, ApiHandler};
impl ApiHandler {
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 {
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
match content_server::load_catalog(&config.data_dir).await {
Ok(catalog) => {
// Only expose public metadata for available items
let items: Vec<serde_json::Value> = catalog
.items
.iter()
.filter(|item| content_server::visible_to(item, peer.as_deref(), known, owner))
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
.map(|i| {
serde_json::json!({
"id": i.id,
@@ -108,7 +74,7 @@ impl ApiHandler {
let invoice_hash = headers
.get("x-invoice-hash")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_ascii_lowercase())
.map(|s| s.to_string())
.or_else(|| {
headers
.get("x-onchain-address")
@@ -116,18 +82,11 @@ impl ApiHandler {
.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."}"#,
),
))
}
};
// 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());
// The authenticated local operator never pays for their own node's
// content: validate the session cookie (same discipline as the model
@@ -139,64 +98,11 @@ impl ApiHandler {
None => false,
};
// Payment settlement is verified on the seller even when no status
// poll preceded this download (e.g. direct payment from another node).
let requires_payment = if !owner_session && headers.contains_key("x-invoice-hash") {
content_server::load_catalog(&config.data_dir)
.await?
.items
.iter()
.any(|item| {
item.id == content_id
&& matches!(item.access, content_server::AccessControl::Paid { .. })
})
} else {
false
};
if requires_payment {
if let Some(hash) = headers.get("x-invoice-hash").and_then(|v| v.to_str().ok()) {
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid payment hash"),
));
}
if let Err(error) = self
.rpc_handler
.settle_content_invoice(hash, content_id)
.await
{
tracing::warn!("Cannot verify peer-file invoice settlement: {error:#}");
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"Payment verification is temporarily unavailable. Retry the download without paying again."}"#,
),
));
}
}
}
// Parse Range header for streaming support
let range = 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"),
))
}
},
};
let range = headers
.get("range")
.and_then(|v| v.to_str().ok())
.and_then(content_server::parse_range_header);
match content_server::serve_content(
&config.data_dir,
@@ -209,7 +115,6 @@ 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()
@@ -290,11 +195,7 @@ 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,
headers: &hyper::HeaderMap,
) -> Result<Response<hyper::Body>> {
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> {
let content_id = path
.strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/invoice"))
@@ -307,7 +208,6 @@ 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();
@@ -321,13 +221,6 @@ 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,
_ => {
@@ -349,18 +242,6 @@ 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
@@ -368,13 +249,7 @@ impl ApiHandler {
.await
{
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
crate::content_invoice::record_pending(
&self.config.data_dir,
&payment_hash,
content_id,
price_sats,
)
.await?;
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
let body = serde_json::json!({
"bolt11": bolt11,
"payment_hash": payment_hash,
@@ -415,20 +290,58 @@ impl ApiHandler {
&self,
path: &str,
) -> Result<Response<hyper::Body>> {
Ok(invoice_status_response(path, |hash, id| async move {
self.rpc_handler.content_invoice_lifecycle(&hash, &id).await
})
.await)
let rest = path.strip_prefix("/content/").unwrap_or("");
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
Some((id, hash)) => (id, hash),
None => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
))
}
};
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
));
}
// The hash must be one we issued for exactly this content item.
match crate::content_invoice::lookup(payment_hash).await {
Some((cid, _)) if cid == content_id => {}
_ => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
))
}
}
// Already paid? Otherwise ask our LND and persist the result.
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
crate::content_invoice::mark_paid(payment_hash).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid });
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
/// 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,
headers: &hyper::HeaderMap,
) -> Result<Response<hyper::Body>> {
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> {
let content_id = path
.strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/onchain"))
@@ -440,80 +353,43 @@ 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 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") {
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
}
_ => {
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"}"#,
),
));
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
))
}
*price_sats
}
_ => {
},
None => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
))
}
};
// 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_method(
&self.config.data_dir,
&address,
content_id,
price_sats,
crate::content_invoice::PaymentMethod::Onchain,
)
.await?;
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
let body = serde_json::json!({
"address": address,
"amount_sats": price_sats,
@@ -563,7 +439,7 @@ impl ApiHandler {
));
}
// The address must be one we issued for exactly this content item.
let price = match crate::content_invoice::lookup(&self.config.data_dir, address).await? {
let price = match crate::content_invoice::lookup(address).await {
Some((cid, price)) if cid == content_id => price,
_ => {
return Ok(build_response(
@@ -574,24 +450,11 @@ impl ApiHandler {
}
};
let mut paid =
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
if !paid {
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."
}))?),
)),
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
crate::content_invoice::mark_paid(address).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid });
@@ -622,7 +485,6 @@ 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()
@@ -669,109 +531,3 @@ impl ApiHandler {
}
}
}
/// Keep invalid input and an unavailable wallet inside the HTTP protocol so
/// buyers can retry delivery without treating a dropped socket as lost payment.
async fn invoice_status_response<F, Fut>(path: &str, settle: F) -> Response<hyper::Body>
where
F: FnOnce(String, String) -> Fut,
Fut: std::future::Future<Output = Result<serde_json::Value>>,
{
let parsed = path
.strip_prefix("/content/")
.and_then(|rest| rest.split_once("/invoice-status/"))
.filter(|(id, hash)| {
!id.is_empty()
&& is_valid_app_id(id)
&& hash.len() == 64
&& hash.bytes().all(|c| c.is_ascii_hexdigit())
});
let Some((id, hash)) = parsed else {
return build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Invalid content ID or payment hash"}"#),
);
};
match settle(hash.to_ascii_lowercase(), id.to_owned()).await {
Ok(body) => build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body.to_string()),
),
Err(_) => {
tracing::warn!("Peer-file payment status verification is temporarily unavailable");
let mut response = build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"Payment verification is temporarily unavailable. Retry without paying again."}"#,
),
);
response.headers_mut().insert(
hyper::header::RETRY_AFTER,
hyper::header::HeaderValue::from_static("5"),
);
response
}
}
}
#[cfg(test)]
mod invoice_status_tests {
use super::*;
#[tokio::test]
async fn malformed_requests_do_not_query_the_wallet() {
for path in [
"/bad",
"/content//invoice-status/aa",
"/content/file/invoice-status/aa",
"/content/file/invoice-status/",
"/content/file/invoice-status/not-a-hash",
] {
let response = invoice_status_response(path, |_, _| async {
panic!("Invalid request reached wallet");
#[allow(unreachable_code)]
Ok(serde_json::json!({"paid":false}))
})
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(response.headers()["content-type"], "application/json");
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
assert!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["error"].is_string()
);
}
}
#[tokio::test]
async fn settlement_results_and_failures_have_explicit_http_responses() {
let hash = "AB".repeat(32);
let path = format!("/content/file/invoice-status/{hash}");
for paid in [false, true] {
let response = invoice_status_response(&path, |hash, id| async move {
assert_eq!(hash, "ab".repeat(32));
assert_eq!(id, "file");
Ok(serde_json::json!({"paid":paid}))
})
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["paid"],
paid
);
}
let response = invoice_status_response(&path, |_, _| async {
anyhow::bail!("private wallet details must not escape")
})
.await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(response.headers()["retry-after"], "5");
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("without paying again"));
assert!(!text.contains("private wallet"));
}
}
@@ -1,241 +0,0 @@
use super::{build_response, ApiHandler};
use crate::content_lightning::{Binding, Journal, Phase};
use anyhow::{Context, Result};
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use tokio::io::AsyncReadExt;
pub(crate) const ROUTE: &str = "/content/lightning/v1/operation";
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Operation {
pub binding: Binding,
pub action: String,
}
impl ApiHandler {
pub(super) async fn handle_lightning_purchase(
&self,
mut request: Request<Body>,
) -> Result<Response<Body>> {
anyhow::ensure!(
request.method() == Method::POST && request.uri().path() == ROUTE,
"Invalid invoice route"
);
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
let mut bytes = Vec::new();
while let Some(chunk) = request.body_mut().data().await {
let chunk = chunk?;
anyhow::ensure!(
bytes.len() + chunk.len() <= 16384,
"Invoice request too large"
);
bytes.extend_from_slice(&chunk)
}
Ok::<_, anyhow::Error>(bytes)
})
.await
.context("Invoice request timed out")??;
let seller = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let buyer = crate::content_auth::authenticate_request(
request.headers(),
&seller,
&Method::POST,
ROUTE,
&bytes,
chrono::Utc::now().timestamp(),
)?;
let operation: Operation = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
operation.binding.buyer_did == buyer && operation.binding.seller_did == seller,
"Invoice peer identity mismatch"
);
anyhow::ensure!(
matches!(
operation.action.as_str(),
"create" | "status" | "cancel" | "download"
),
"Invalid invoice action"
);
let binding = &operation.binding;
let journal = Journal::open(&self.config.data_dir).await?;
let mut saved = journal.seller(binding)?;
if saved.is_none() {
anyhow::ensure!(
operation.action == "create",
"Unknown original invoice operation"
);
anyhow::ensure!(
!binding.content_id.starts_with("registered_"),
"Registered rentals use their native purchase contract"
);
let catalog = crate::content_server::load_catalog(&self.config.data_dir).await?;
let item = catalog
.items
.iter()
.find(|v| v.id == binding.content_id)
.context("Shared item unavailable")?;
let visible = match &item.availability {
crate::content_server::Availability::Nobody => false,
crate::content_server::Availability::AllPeers => true,
crate::content_server::Availability::Specific { peers } => peers.contains(&buyer),
};
anyhow::ensure!(visible, "Item is not shared with this buyer");
anyhow::ensure!(
matches!(&item.access,crate::content_server::AccessControl::Paid{price_sats,..} if *price_sats==binding.price_sats)
&& crate::content_server::method_accepted(&item.access, "lightning"),
"Invoice price or accepted method changed"
);
crate::content_server::ensure_payment_source_available(&self.config.data_dir, item)
.await?;
let source = crate::content_server::content_file_path(&self.config.data_dir, item);
let roots = [
self.config.data_dir.join("content/files"),
self.config.data_dir.join("filebrowser"),
];
let (root, relative) = roots
.iter()
.find_map(|root| {
source
.strip_prefix(root)
.ok()
.map(|p| (root.clone(), p.to_path_buf()))
})
.context("Unsupported invoice source root")?;
let data = self.config.data_dir.clone();
let id = binding.content_id.clone();
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelCopy {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)));
let cancelled = cancel_copy.0.clone();
let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::prepare(
&data,
&root,
&id,
&relative,
&crate::media_registration::Limits {
max_bytes: 64 * 1024 * 1024 * 1024,
cancelled: &cancelled,
},
64 * 1024 * 1024 * 1024,
512 * 1024 * 1024,
|_| Ok(()),
)
})
.await??;
anyhow::ensure!(
snapshot.size == item.size_bytes,
"Shared file changed before invoice"
);
// Source metadata is private and committed before AddInvoice dispatch.
let record = crate::content_server::publish_snapshot_invoice(
&self.config.data_dir,
item,
&journal,
binding.clone(),
crate::content_lightning::RetainedFile {
sha256: snapshot.sha256,
size: snapshot.size,
filename: item.filename.clone(),
mime_type: item.mime_type.clone(),
},
)
.await?;
saved = Some(record);
}
let mut saved = saved.context("Missing invoice operation")?;
anyhow::ensure!(
saved.source.is_some(),
"Original invoice source is not prepared; no new invoice dispatched"
);
let status = if operation.action == "cancel" && saved.phase == Phase::Prepared {
saved.phase = Phase::CanceledUnpaid;
journal.save_seller(&saved)?;
saved.status()
} else if operation.action != "create"
&& operation.action != "cancel"
&& saved.phase == Phase::Prepared
{
saved.status()
} else {
self.rpc_handler
.drive_external_invoice(&journal, binding, operation.action == "cancel")
.await?
};
// The original legacy delivery mechanism remains usable by its hash.
if status.bolt11.is_some() {
crate::content_invoice::record_pending(
&self.config.data_dir,
&status.payment_hash,
&binding.content_id,
binding.price_sats,
)
.await?;
if status.state == Phase::Settled {
crate::content_invoice::mark_paid(&self.config.data_dir, &status.payment_hash)
.await?;
}
}
if operation.action == "download" {
anyhow::ensure!(
status.state == Phase::Settled,
"Original invoice has not settled"
);
let source = status
.source
.as_ref()
.context("Original invoice snapshot is missing")?;
let data = self.config.data_dir.clone();
let id = binding.content_id.clone();
let retained = source.clone();
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelCopy {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)));
let cancelled = cancel_copy.0.clone();
let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
})
.await??;
let stream = futures_util::stream::try_unfold(
(tokio::fs::File::from_std(snapshot.file), source.size),
|(mut file, left)| async move {
if left == 0 {
return Ok::<_, std::io::Error>(None);
}
let mut bytes = vec![0; left.min(65536) as usize];
let count = file.read(&mut bytes).await?;
if count == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Original invoice snapshot ended early",
));
}
bytes.truncate(count);
Ok(Some((bytes, (file, left - count as u64))))
},
);
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", &source.mime_type)
.header("Content-Length", source.size)
.header("Cache-Control", "private, no-store")
.body(Body::wrap_stream(stream))?);
}
Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&status)?),
))
}
}
+3 -98
View File
@@ -1,20 +1,13 @@
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;
@@ -391,12 +384,6 @@ 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()
@@ -427,16 +414,6 @@ impl ApiHandler {
.await;
}
// Owner terminal attachment — the browser socket is disposable; the
// authenticated tmux session survives reconnects and browser closes.
if method == Method::GET && path == "/ws/terminal" {
if !self.is_authenticated(req.headers()).await {
tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_terminal_websocket(req).await;
}
// Remote input WebSocket — companion app sends keyboard/mouse events
if method == Method::GET && path == "/ws/remote-input" {
if !self.is_authenticated(req.headers()).await {
@@ -467,32 +444,6 @@ 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();
@@ -555,15 +506,6 @@ impl ApiHandler {
.unwrap())
}
(Method::GET, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
terminal::list_response().await
}
(Method::POST, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
terminal::create(&body_bytes).await
}
// Node message — P2P endpoint (authenticated by source validation, not cookie)
(Method::POST, "/archipelago/node-message") => {
Self::handle_node_message(body_bytes).await
@@ -642,15 +584,6 @@ 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
@@ -658,7 +591,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, &headers).await
self.handle_content_invoice(p).await
}
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
self.handle_content_invoice_status(p).await
@@ -669,7 +602,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, &headers).await
self.handle_content_onchain(p).await
}
// Content serving — peers access shared content over Tor (no session auth);
@@ -679,7 +612,7 @@ impl ApiHandler {
}
// Content catalog — list available content (no session auth, for peers)
(Method::GET, "/content") => self.handle_content_catalog(&headers).await,
(Method::GET, "/content") => Self::handle_content_catalog(&self.config).await,
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
@@ -690,34 +623,6 @@ impl ApiHandler {
// (upstream Gitea has no ACAO header) or CSP (IP-port upstream
// falls outside `connect-src`). Session-authenticated so only
// the logged-in node owner can spin up fetches.
(Method::GET, "/api/node-app-catalog") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let data_dir = self.config.data_dir.clone();
let result = tokio::task::spawn_blocking(move || {
crate::container::node_catalog::verified_body(&data_dir)
})
.await
.unwrap_or_else(|error| Err(anyhow::anyhow!(error)));
let (status, body) = match result {
Ok(Some(body)) => (StatusCode::OK, body),
Ok(None) => (StatusCode::NOT_FOUND, "{}".to_owned()),
Err(error) => {
tracing::warn!("Node demo catalog rejected: {error}");
(
StatusCode::CONFLICT,
"{\"error\":\"Node demo catalog is unavailable\"}".to_owned(),
)
}
};
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "private, no-store")
.body(hyper::Body::from(body))?)
}
(Method::GET, "/api/app-catalog") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
@@ -1,712 +0,0 @@
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());
}
}
+51 -21
View File
@@ -238,13 +238,54 @@ impl ApiHandler {
return bad("invalid onion or content id");
}
// 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()))),
// 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).
}
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
@@ -252,31 +293,20 @@ 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
// slow links and aborted the download mid-stream.
// Tor 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)) => {
if resp.status().is_redirection() {
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from("{\"error\":\"Peer media redirects are not allowed\"}"),
));
}
Ok((resp, _transport)) => {
let status = resp.status().as_u16();
let rh = resp.headers().clone();
let mut builder = Response::builder()
.status(status)
.header("Accept-Ranges", "bytes")
.header("X-Archipelago-Transport", transport.to_string());
.header("Accept-Ranges", "bytes");
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);
@@ -1,206 +0,0 @@
//! 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)?),
))
}
}
@@ -1,437 +0,0 @@
//! 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());
}
}
@@ -1,548 +0,0 @@
//! 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,17 +198,6 @@ 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}"))?;
@@ -456,41 +445,6 @@ 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]
@@ -1,279 +0,0 @@
//! Owner-authenticated terminal sessions backed by private tmux processes.
//!
//! Browser connections are disposable attachments. The tmux process and its
//! metadata remain on the node so a reconnect resumes the same workspace.
use anyhow::{anyhow, Result};
use futures_util::{SinkExt, StreamExt};
use hyper::{Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tokio_tungstenite::tungstenite::Message;
fn tmux_key_for_input(data: &str) -> Option<&'static str> {
match data {
"\u{3}" => Some("C-c"),
"\u{4}" => Some("C-d"),
"\r" | "\n" => Some("Enter"),
"\u{7f}" => Some("BSpace"),
"\t" => Some("Tab"),
"\u{1b}[A" => Some("Up"),
"\u{1b}[B" => Some("Down"),
"\u{1b}[C" => Some("Right"),
"\u{1b}[D" => Some("Left"),
"\u{1b}[H" => Some("Home"),
"\u{1b}[F" => Some("End"),
"\u{1b}[3~" => Some("DC"),
_ => None,
}
}
use uuid::Uuid;
use super::{build_response, ApiHandler};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SessionRecord {
pub schema: u8,
pub id: String,
pub name: String,
pub workspace: String,
pub state: String,
pub tmux: String,
pub updated_at: i64,
}
#[derive(Debug, Deserialize)]
struct CreateRequest {
name: Option<String>,
workspace: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ClientMessage {
#[serde(rename = "type")]
kind: String,
data: Option<String>,
cols: Option<u16>,
rows: Option<u16>,
}
pub(crate) fn state_dir() -> PathBuf {
std::env::var_os("ARCHY_SESSION_STATE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
if let Some(xdg) = std::env::var_os("XDG_STATE_HOME") {
PathBuf::from(xdg).join("archipelago/sessions")
} else if let Some(home) = std::env::var_os("HOME") {
let user_dir = PathBuf::from(home).join(".local/state/archipelago/sessions");
if user_dir.exists() {
user_dir
} else {
PathBuf::from("/var/lib/archipelago/sessions")
}
} else {
PathBuf::from("/var/lib/archipelago/sessions")
}
})
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
}
async fn read_record(dir: &Path, id: &str) -> Result<SessionRecord> {
if !valid_id(id) {
return Err(anyhow!("invalid session id"));
}
let bytes = tokio::fs::read(dir.join(format!("{id}.json"))).await?;
Ok(serde_json::from_slice(&bytes)?)
}
async fn tmux_alive(name: &str) -> bool {
Command::new("tmux")
.args(["has-session", "-t", name])
.output()
.await
.map(|out| out.status.success())
.unwrap_or(false)
}
async fn write_record(dir: &Path, record: &SessionRecord) -> Result<()> {
tokio::fs::create_dir_all(dir).await?;
let tmp = dir.join(format!(".{}.tmp-{}", record.id, Uuid::new_v4()));
let final_path = dir.join(format!("{}.json", record.id));
tokio::fs::write(&tmp, serde_json::to_vec_pretty(record)?).await?;
tokio::fs::rename(tmp, final_path).await?;
Ok(())
}
pub(crate) async fn list(dir: &Path) -> Result<Vec<SessionRecord>> {
let mut out = Vec::new();
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(error) => return Err(error.into()),
};
while let Some(entry) = entries.next_entry().await? {
if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = tokio::fs::read(entry.path()).await else {
continue;
};
let Ok(mut record) = serde_json::from_slice::<SessionRecord>(&bytes) else {
continue;
};
record.state = if tmux_alive(&record.tmux).await {
"detached".into()
} else if record.state == "running" {
"interrupted".into()
} else {
record.state.clone()
};
out.push(record);
}
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(out)
}
pub(crate) async fn list_response() -> Result<Response<hyper::Body>> {
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_vec(
&list(&state_dir()).await?,
)?))?)
}
pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
let request: CreateRequest = serde_json::from_slice(body).unwrap_or(CreateRequest {
name: None,
workspace: None,
});
let workspace_was_requested = request.workspace.is_some();
let workspace = request.workspace.unwrap_or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("Work")
.to_string_lossy()
.into_owned()
});
let workspace_path = PathBuf::from(&workspace);
if !workspace_was_requested {
tokio::fs::create_dir_all(&workspace_path).await?;
}
if !workspace_path.is_absolute() || !workspace_path.is_dir() || workspace_path == Path::new("/")
{
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"workspace must be an existing non-root directory"}"#),
));
}
let id = format!("s-{}", Uuid::new_v4().simple());
let tmux_name = format!("archy-{id}");
let output = Command::new("tmux")
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
.output()
.await?;
if !output.status.success() {
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(r#"{"error":"tmux could not start the session"}"#),
));
}
let name = request
.name
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| "Work".into());
let record = SessionRecord {
schema: 1,
id,
name,
workspace,
state: "running".into(),
tmux: tmux_name,
updated_at: chrono::Utc::now().timestamp(),
};
write_record(&state_dir(), &record).await?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_vec(&record)?))?)
}
pub(crate) async fn websocket(req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
let id = req
.uri()
.query()
.and_then(|query| {
query
.split('&')
.find_map(|part| part.strip_prefix("session="))
})
.unwrap_or("")
.to_string();
let record = read_record(&state_dir(), &id).await?;
if !tmux_alive(&record.tmux).await {
return Ok(build_response(
StatusCode::CONFLICT,
"application/json",
hyper::Body::from(r#"{"error":"session is not running"}"#),
));
}
let (response, ws_fut) =
hyper_ws_listener::create_ws(req).map_err(|e| anyhow!("WebSocket upgrade failed: {e}"))?;
if let Some(ws_fut) = ws_fut {
tokio::spawn(async move {
let Ok(Ok(stream)) = ws_fut.await else { return };
let (mut tx, mut rx) = stream.split();
let mut interval = tokio::time::interval(std::time::Duration::from_millis(150));
let mut last = String::new();
loop {
tokio::select! {
_ = interval.tick() => {
// Capture the visible pane only. Asking tmux for a large
// historical range injects hundreds of blank rows into
// xterm on every reconnect, producing a misleading
// giant initial scrollbar. xterm owns live scrollback.
let output = Command::new("tmux").args(["capture-pane", "-p", "-e", "-t", &record.tmux]).output().await;
if let Ok(output) = output {
let text = String::from_utf8_lossy(&output.stdout).into_owned();
if text != last { last = text.clone(); if tx.send(Message::Text(serde_json::json!({"type":"output", "data":text}).to_string())).await.is_err() { break; } }
} else { break; }
}
message = rx.next() => match message {
Some(Ok(Message::Text(text))) => {
let Ok(message) = serde_json::from_str::<ClientMessage>(&text) else { continue };
match message.kind.as_str() {
"input" => if let Some(data) = message.data { let mut command = Command::new("tmux"); command.args(["send-keys", "-t", &record.tmux]); if let Some(key) = tmux_key_for_input(&data) { command.arg(key); } else { command.args(["-l", "--", &data]); } let _ = command.output().await; },
"resize" => if let (Some(cols), Some(rows)) = (message.cols, message.rows) { let _ = Command::new("tmux").args(["resize-window", "-t", &record.tmux, "-x", &cols.to_string(), "-y", &rows.to_string()]).output().await; },
"ping" => { let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.into())).await; },
_ => {}
}
}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(_)) => break,
_ => {}
}
}
}
});
}
Ok(response)
}
impl ApiHandler {
pub(super) async fn handle_terminal_websocket(
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
websocket(req).await
}
}
+15 -277
View File
@@ -5,47 +5,10 @@
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> {
@@ -299,7 +262,7 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let report = collector_report(params.context("Missing telemetry report payload")?, None)?;
let report = params.context("Missing telemetry report payload")?;
// Validate required fields
let node_id = report
@@ -322,13 +285,6 @@ 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
@@ -383,9 +339,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?;
let mut authoritative = federation_ids(&self.config.data_dir).await?;
authoritative.extend(fed_nodes.iter().map(|n| n.did.clone()));
let fed_nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
for n in fed_nodes
.iter()
.filter(|n| n.trust_level == crate::federation::TrustLevel::Trusted)
@@ -396,19 +352,20 @@ impl RpcHandler {
(Some(u), Some(t)) if t > 0 => {
serde_json::json!((u as f64 / t as f64 * 100.0).round())
}
_ => serde_json::Value::Null,
_ => serde_json::json!(0),
}
};
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());
.or_else(|| n.last_seen.clone())
.unwrap_or_else(|| n.added_at.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),
"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()),
"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),
"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(),
@@ -422,7 +379,6 @@ 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);
@@ -445,20 +401,9 @@ impl RpcHandler {
match tokio::fs::read_to_string(entry.path()).await {
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
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);
}
}
Ok(mut report) => {
annotate_fleet_report(&mut report);
nodes.push(report);
}
Err(e) => {
warn!(file = %name, error = %e, "Skipping corrupt fleet report");
@@ -505,14 +450,6 @@ 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
@@ -524,15 +461,8 @@ 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(),
}))
@@ -546,7 +476,6 @@ 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
@@ -569,30 +498,19 @@ 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);
}
}
}
@@ -643,7 +561,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_seconds() >= -60 && age.num_seconds() < 1800
age.num_minutes() < 30
})
.unwrap_or(false);
@@ -651,9 +569,7 @@ 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 age.num_seconds() < -60 {
"unknown (clock ahead)".to_string()
} else if mins < 1 {
if mins < 1 {
"just now".to_string()
} else if mins < 60 {
format!("{}m ago", mins)
@@ -670,181 +586,3 @@ 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"
);
}
}
+1 -1
View File
@@ -136,7 +136,7 @@ impl RpcHandler {
/// ~30% of UI calls error out even though the node is perfectly healthy.
/// With retry + backoff, the UI sees a uniform slow-but-successful
/// response instead of intermittent failures.
pub(in crate::api::rpc) async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
&self,
client: &reqwest::Client,
method: &str,
File diff suppressed because it is too large Load Diff
+6 -368
View File
@@ -31,24 +31,7 @@ async fn files_copy_routes_media_and_sanitizes_the_filename() {
("audio/mpeg", "Music"),
("text/plain", "Documents"),
] {
crate::content_owned::record_purchase(
dir.path(),
"seller.onion",
"id",
"../name #?.bin",
mime,
b"paid",
1,
"cashu",
"now",
)
.await
.unwrap();
let item = crate::content_owned::list_owned_checked(dir.path())
.await
.unwrap()
.remove(0);
let relative = file_cached_purchase_in_files(dir.path(), &item)
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
.await
.unwrap();
assert!(relative.starts_with(&format!("{folder}/name #?")));
@@ -64,26 +47,11 @@ 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();
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!(
file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
.await
.is_err()
);
assert!(!dir.path().join("filebrowser").exists());
}
@@ -103,333 +71,3 @@ fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
);
}
}
#[tokio::test]
async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavailable() {
let dir = tempfile::tempdir().unwrap();
assert!(
existing_paid_content(dir.path(), "seller.onion", "id", None, false)
.await
.unwrap()
.is_none()
);
crate::content_owned::record_purchase(
dir.path(),
"seller.onion",
"id",
"file.txt",
"text/plain",
b"paid",
1,
"cashu",
"now",
)
.await
.unwrap();
for (id, filename) in [("id", None), ("duplicate-id", Some("/file.txt"))] {
let cached = existing_paid_content(dir.path(), "seller.onion", id, filename, false)
.await
.unwrap()
.unwrap();
assert_eq!(cached["paid_sats"], 0);
assert_eq!(cached["already_owned"], true);
assert_eq!(cached["data"], "cGFpZA==");
}
assert!(
existing_paid_content(dir.path(), "different.onion", "id", None, false)
.await
.unwrap()
.is_none()
);
tokio::fs::remove_file(dir.path().join("purchased-content/seller.onion/id"))
.await
.unwrap();
assert!(
existing_paid_content(dir.path(), "seller.onion", "id", None, false)
.await
.unwrap_err()
.to_string()
.contains("No new payment")
);
tokio::fs::write(dir.path().join("purchased-content/owned.json"), b"damaged")
.await
.unwrap();
assert!(
existing_paid_content(dir.path(), "seller.onion", "other-id", None, false)
.await
.unwrap_err()
.to_string()
.contains("no new payment")
);
assert_eq!(
tokio::fs::read(dir.path().join("purchased-content/owned.json"))
.await
.unwrap(),
b"damaged"
);
}
#[tokio::test]
async fn inline_download_limits_known_and_chunked_bodies_without_draining_them() {
use futures_util::StreamExt;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
let response: reqwest::Response = hyper::Response::new("small").into();
assert!(bounded_content_bytes(response, 4).await.is_err());
let response: reqwest::Response = hyper::Response::new("small").into();
assert_eq!(bounded_content_bytes(response, 5).await.unwrap(), b"small");
let consumed = Arc::new(AtomicUsize::new(0));
let counter = consumed.clone();
let chunks = futures_util::stream::iter(0..1000).map(move |_| {
counter.fetch_add(1, Ordering::SeqCst);
Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"abc"))
});
let body = reqwest::Body::wrap_stream(chunks);
let response: reqwest::Response = hyper::Response::new(body).into();
assert!(bounded_content_bytes(response, 4).await.is_err());
assert_eq!(consumed.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn onchain_delivery_streams_to_owned_cache_and_preserves_incomplete_recovery() {
use tokio::io::AsyncReadExt;
let dir = tempfile::tempdir().unwrap();
let response: reqwest::Response = hyper::Response::builder()
.header("content-length", "2097152")
.body(hyper::Body::from(vec![71u8; 2 * 1024 * 1024]))
.unwrap()
.into();
let item = cache_peer_response(
dir.path(),
"seller.onion",
"film",
"film.mp4",
"video/mp4",
1,
"onchain",
response,
)
.await
.unwrap();
assert!(item.download_complete);
assert_eq!(item.ecash_backend, "onchain");
let (_, mut file) = crate::content_owned::open_owned(dir.path(), "seller.onion", "film")
.await
.unwrap()
.unwrap();
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).await.unwrap();
assert_eq!(bytes, vec![71u8; 2 * 1024 * 1024]);
let reply = cached_purchase_response(dir.path(), "seller.onion", "film", true, 0)
.await
.unwrap();
assert_eq!(reply["owned"], true);
assert!(reply.get("data").is_none());
let truncated: reqwest::Response = hyper::Response::builder()
.header("content-length", "100")
.body(hyper::Body::wrap_stream(futures_util::stream::iter([
Ok(bytes::Bytes::from_static(b"short")),
Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"connection closed before delivery completed",
)),
])))
.unwrap()
.into();
assert!(cache_peer_response(
dir.path(),
"seller.onion",
"interrupted",
"film.mp4",
"video/mp4",
1,
"onchain",
truncated
)
.await
.is_err());
let entries = crate::content_owned::list_owned_checked(dir.path())
.await
.unwrap();
assert!(
!entries
.iter()
.find(|item| item.content_id == "interrupted")
.unwrap()
.download_complete
);
assert!(
existing_paid_content(dir.path(), "seller.onion", "interrupted", None, true)
.await
.is_err()
);
}
#[test]
fn share_creation_stays_hidden_without_explicit_visibility_and_rejects_invalid_policy() {
let empty = serde_json::json!({});
assert!(matches!(
parse_content_availability(&empty, "nobody").unwrap(),
Availability::Nobody
));
assert!(matches!(
parse_content_access(&empty).unwrap(),
AccessControl::Free
));
for invalid in [
serde_json::json!({"access":null}),
serde_json::json!({"access":"paid","price_sats":0}),
serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":["unsupported"]}),
serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":"ecash"}),
] {
assert!(parse_content_access(&invalid).is_err());
}
let paid = serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":["ecash"],"availability":"all_peers"});
assert!(matches!(
parse_content_access(&paid).unwrap(),
AccessControl::Paid { price_sats: 1, .. }
));
assert!(matches!(
parse_content_availability(&paid, "nobody").unwrap(),
Availability::AllPeers
));
}
#[tokio::test]
async fn repeated_share_returns_stable_id_and_complete_policy() {
let dir = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = dir.path().to_path_buf();
config.dev_mode = false;
let sessions = crate::session::SessionStore::new_for_tests(dir.path().join("sessions.json"));
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
sessions,
None,
None,
)
.await
.unwrap();
let first = handler
.handle_content_publish(Some(serde_json::json!({
"filename":"film.mp4", "access":"paid", "price_sats":1,
"accepted_methods":["ecash"], "availability":"nobody"
})))
.await
.unwrap();
let second = handler
.handle_content_publish(Some(serde_json::json!({
"filename":"film.mp4", "access":"paid", "price_sats":2,
"accepted_methods":["lightning"], "availability":"nobody"
})))
.await
.unwrap();
assert_eq!(first["item"]["id"], second["item"]["id"]);
let catalog = content_server::load_catalog(dir.path()).await.unwrap();
assert_eq!(catalog.items.len(), 1);
let item = &catalog.items[0];
assert_eq!(second["item"]["id"].as_str(), Some(item.id.as_str()));
assert!(
matches!(&item.access, AccessControl::Paid { price_sats: 2, accepted } if accepted == &["lightning"])
);
assert!(matches!(item.availability, Availability::Nobody));
assert!(handler
.handle_content_configure(Some(serde_json::json!({
"id":item.id, "access":"free"
})))
.await
.is_err());
assert!(matches!(
content_server::load_catalog(dir.path())
.await
.unwrap()
.items[0]
.access,
AccessControl::Paid { price_sats: 2, .. }
));
}
#[tokio::test]
async fn legacy_payment_routes_refuse_fresh_spending_but_preserve_paid_cache() {
let data = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = data.path().to_path_buf();
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let onion = format!("{}.onion", "a".repeat(56));
for method in ["cashu", "fedimint", "auto"] {
let error = handler
.handle_content_download_peer_paid(Some(serde_json::json!({
"onion": onion, "content_id": "file", "price_sats": 1,
"method": method, "cache_only": true
})))
.await
.unwrap_err();
assert!(
error.to_string().contains("No payment was sent"),
"{error:#}"
);
}
assert!(handler
.handle_content_request_invoice(None)
.await
.unwrap_err()
.to_string()
.contains("saved Lightning purchase flow"));
assert!(handler
.handle_content_request_onchain(None)
.await
.unwrap_err()
.to_string()
.contains("saved Bitcoin purchase flow"));
assert!(!data.path().join("wallet").exists());
crate::content_owned::record_purchase(
data.path(),
&onion,
"file",
"paid.txt",
"text/plain",
b"previously paid",
1,
"fedimint",
"2026-10-01T00:00:00Z",
)
.await
.unwrap();
for cache_only in [true, false] {
let result = handler
.handle_content_download_peer_paid(Some(serde_json::json!({
"onion": onion, "content_id": "file", "price_sats": 1,
"method": "fedimint", "cache_only": cache_only
})))
.await
.unwrap();
assert_eq!(result["paid_sats"], 0);
assert_eq!(result["already_owned"], true);
if cache_only {
assert_eq!(result["owned"], true);
} else {
use base64::Engine;
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(result["data"].as_str().unwrap())
.unwrap(),
b"previously paid"
);
}
}
assert!(!data.path().join("wallet").exists());
}
@@ -11,26 +11,6 @@ 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,
@@ -152,9 +132,6 @@ impl RpcHandler {
"lnd.newaddress" => self.handle_lnd_newaddress().await,
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.bump-quote" => self.handle_lnd_bump_quote(params).await,
"lnd.bump-submit" => self.handle_lnd_bump_submit(params).await,
"lnd.bump-status" => self.handle_lnd_bump_status(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
@@ -179,8 +156,6 @@ 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,
@@ -344,8 +319,6 @@ 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,
@@ -354,43 +327,6 @@ impl RpcHandler {
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
"content.playback-prepare" => self.handle_playback_prepare(params, session_token).await,
"content.playback-start" => self.handle_playback_start(params, session_token).await,
"content.playback-status" => self.handle_playback_status(params, session_token).await,
"content.rental-purchase" => self.handle_content_rental_purchase(params).await,
"content.onchain-cancel" => self.handle_onchain_operation(params, "cancel").await,
"content.onchain-attempt" => self.handle_onchain_operation(params, "lookup").await,
"content.onchain-create" => self.handle_onchain_operation(params, "create").await,
"content.onchain-expose" => self.handle_onchain_operation(params, "expose").await,
"content.onchain-prepare" => self.handle_onchain_operation(params, "prepare").await,
"content.onchain-pay" => self.handle_onchain_operation(params, "pay").await,
"content.onchain-recover" => self.handle_onchain_operation(params, "status").await,
"content.onchain-download" => self.handle_onchain_operation(params, "download").await,
"content.invoice-pay" => self.handle_lightning_operation(params, "pay").await,
"content.invoice-download" => self.handle_lightning_operation(params, "download").await,
"content.invoice-attempt" => self.handle_lightning_operation(params, "lookup").await,
"content.invoice-retry-native" => {
self.handle_lightning_operation(params, "retry").await
}
"content.invoice-create" => self.handle_lightning_operation(params, "create").await,
"content.invoice-recover" => self.handle_lightning_operation(params, "status").await,
"content.invoice-cancel" => self.handle_lightning_operation(params, "cancel").await,
"content.purchase" => self.handle_content_purchase(params).await,
"content.cancel-purchase" => self.handle_content_cancel_purchase(params).await,
"content.payment-status" => self.handle_content_payment_status(params).await,
"media.registration.context" => {
self.handle_media_registration_context(params.unwrap_or_default())
.await
}
"media.registration.prepare" => {
self.handle_media_registration_prepare(params.unwrap_or_default())
.await
}
"media.registration.resolve" => {
self.handle_media_registration_resolve(params.unwrap_or_default())
.await
}
"content.owned-list" => self.handle_content_owned_list().await,
"content.owned-get" => self.handle_content_owned_get(params).await,
"content.request-invoice" => self.handle_content_request_invoice(params).await,
@@ -7,8 +7,6 @@ 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";
@@ -462,65 +460,37 @@ impl RpcHandler {
let identity_dir = self.config.data_dir.join("identity");
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
// 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 synced = 0u32;
let mut failed = 0u32;
let mut results = Vec::new();
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");
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(),
}));
}
}
}
@@ -602,39 +572,14 @@ 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,
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,
0.0,
0,
0,
0,
0,
0,
tor_active,
server_name,
nostr_npub,
@@ -1279,120 +1224,76 @@ 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
.as_deref()
.clone()
.ok_or_else(|| anyhow::anyhow!("Tor address not available"))?;
let local_fips_npub = identity::fips_npub(&self.config.data_dir.join("identity"))
.await
.unwrap_or(None);
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 invite_code = federation::create_invite(
&self.config.data_dir,
&local_did,
local_onion,
&data.server_info.pubkey,
&local_onion,
&local_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
}
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,
// 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,
self.config.nostr_tor_proxy.as_deref(),
)
.await;
if result.is_err() {
warn!(request_id = %reply.request_id, "Peer approval delivery deferred; durable retry scheduled");
}
Ok(result.is_ok())
}
.await?;
/// 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(())
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,
}))
}
/// federation.reject-request — drop a pending request and, if requested,
@@ -1422,19 +1323,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.handshake_relays().await,
&self.config.nostr_relays,
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 }))
}
@@ -1460,7 +1361,16 @@ impl RpcHandler {
.and_then(|v| v.as_bool())
.unwrap_or(true);
let req = pending::cancel_outbound(&self.config.data_dir, id).await?;
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
);
}
if notify {
let identity_dir = self.config.data_dir.join("identity");
@@ -1470,7 +1380,7 @@ impl RpcHandler {
&identity_dir,
&req.from_nostr_pubkey,
reason,
&self.handshake_relays().await,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await
@@ -1483,6 +1393,7 @@ 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 }))
}
@@ -1,455 +0,0 @@
//! 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,6 +1,4 @@
mod handlers;
#[cfg(test)]
mod handshake_tests;
use anyhow::Result;
+3 -21
View File
@@ -276,11 +276,6 @@ 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> {
@@ -345,7 +340,6 @@ 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.
@@ -362,16 +356,6 @@ 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 =
@@ -389,7 +373,7 @@ impl RpcHandler {
let local_name = data.server_info.name.clone();
match crate::federation::accept_invite(
&self.config.data_dir,
&scoped_invite,
invite_code,
&local_did,
&local_onion,
&local_pubkey,
@@ -432,11 +416,10 @@ impl RpcHandler {
.await;
}
pending::complete_outbound(
pending::set_state(
&self.config.data_dir,
&row_id,
&hs.from_nostr_pubkey,
&node.did,
PendingState::Approved,
)
.await?;
applied_invites.push(node.did);
@@ -451,7 +434,6 @@ impl RpcHandler {
}
}
HandshakeMessage::PeerReject { reason } => {
let _decision = pending::outbound_decision_guard().await;
let pendings = pending::load_pending(&self.config.data_dir).await?;
if let Some(row) = pendings.iter().find(|r| {
r.outbound
@@ -1,8 +1,6 @@
use super::*;
use crate::api::rpc::RpcHandler;
use crate::identity_manager::{
is_node_identity, IdentityManager, IdentityProfile, IdentityPurpose,
};
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
use crate::network::did_dht;
use anyhow::{Context, Result};
use nostr_sdk::ToBech32;
@@ -40,7 +38,7 @@ impl RpcHandler {
.into_iter()
.map(|id| {
let is_default = default_id.as_deref() == Some(&id.id);
let is_node = is_node_identity(&id, &node_pubkey_hex);
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
let (nostr_pubkey, nostr_npub) = if is_node {
(
node_nostr_hex.clone().or(id.nostr_pubkey),
@@ -112,25 +110,6 @@ 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,
@@ -1,358 +0,0 @@
use super::RpcHandler;
use crate::{
api::handler::lightning_purchase::{Operation, ROUTE},
content_lightning::{Binding, BuyerRecord, Journal, Phase, Status},
};
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Params {
onion: String,
content_id: String,
price_sats: Option<u64>,
operation_id: Option<String>,
#[serde(default)]
external_exposure: bool,
}
impl RpcHandler {
pub(super) async fn handle_lightning_operation(
&self,
params: Option<serde_json::Value>,
action: &str,
) -> Result<serde_json::Value> {
let params: Params = serde_json::from_value(params.context("Missing invoice operation")?)?;
let peer =
crate::federation::load_unique_payment_peer(&self.config.data_dir, &params.onion)
.await?;
let fips = peer
.fips_npub
.context("Seller has no authenticated mesh connection")?;
let buyer =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
.await?
.did_key()?;
anyhow::ensure!(buyer != peer.did, "Cannot buy a file from this same node");
let _admission = crate::content_payment_admission::lock(
&self.config.data_dir,
&buyer,
&peer.did,
&params.content_id,
)
.await?;
if matches!(action, "create" | "pay" | "retry") || params.external_exposure {
self.ensure_onchain_allows_other_rail(&buyer, &peer.did, &params.content_id)
.await?;
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
anyhow::ensure!(
cashu
.find_buyers(&buyer, &peer.did, &params.content_id)
.await?
.iter()
.all(|r| r.phase == crate::content_purchase::BuyerPhase::Cancelled),
"Recover or cancel the original Cashu purchase before exposing a Lightning invoice"
);
}
let journal = Journal::open(&self.config.data_dir).await?;
let original = if let Some(id) = &params.operation_id {
journal.buyer(id)?
} else {
journal.buyer_for(&buyer, &peer.did, &params.content_id)?
};
if let Some(record) = &original {
anyhow::ensure!(
record.binding.buyer_did == buyer
&& record.binding.seller_did == peer.did
&& record.binding.content_id == params.content_id
&& record.seller_onion == params.onion,
"Original invoice belongs to another purchase"
);
}
if action == "lookup" {
return Ok(match original {
None => serde_json::json!({"attempt":null}),
Some(mut record) => {
let mut native_result = record.native_result.clone();
if native_result.is_none() && record.native_dispatched {
if let Some(status) = &record.last {
if let Ok(payment) = self
.handle_lnd_paymentstatus(Some(
serde_json::json!({"payment_hash":status.payment_hash}),
))
.await
{
if let Some(result @ ("failed" | "succeeded")) =
payment["status"].as_str()
{
native_result = Some(result.to_owned());
record.native_result = native_result.clone();
journal.save_buyer(&record)?;
}
}
}
}
let native_failed =
!record.external_exposure && native_result.as_deref() == Some("failed");
let native_succeeded = native_result.as_deref() == Some("succeeded");
if !record.external_exposure {
if let Some(status) = record.last.as_mut() {
status.bolt11 = None;
}
}
serde_json::json!({"attempt":{"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"external_exposure":record.external_exposure,"native_failed":native_failed,"native_succeeded":native_succeeded,"status":record.last}})
}
});
}
let mut record = if let Some(record) = original {
record
} else {
anyhow::ensure!(
action == "create" && params.operation_id.is_none(),
"Original invoice operation is unavailable"
);
BuyerRecord {
binding: Binding {
id: uuid::Uuid::new_v4().to_string(),
buyer_did: buyer.clone(),
seller_did: peer.did.clone(),
content_id: params.content_id.clone(),
price_sats: params
.price_sats
.context("Expected invoice price is required")?,
},
seller_onion: params.onion.clone(),
external_exposure: false,
native_retired: false,
native_replacement: None,
native_dispatched: false,
native_result: None,
last: None,
}
};
anyhow::ensure!(
record.binding.buyer_did == buyer
&& record.binding.seller_did == peer.did
&& record.binding.content_id == params.content_id
&& record.seller_onion == params.onion
&& params
.operation_id
.as_ref()
.is_none_or(|id| id == &record.binding.id),
"Original invoice operation changed"
);
if action == "retry" {
anyhow::ensure!(
params.operation_id.is_some()
&& !params.external_exposure
&& params.price_sats == Some(record.binding.price_sats),
"Explicit original native retry and original price required"
);
if record.native_result.is_none()
&& record.native_dispatched
&& !record.external_exposure
{
let hash = &record
.last
.as_ref()
.context("Original invoice metadata missing")?
.payment_hash;
let payment = self
.handle_lnd_paymentstatus(Some(serde_json::json!({"payment_hash":hash})))
.await?;
if matches!(payment["status"].as_str(), Some("failed" | "succeeded")) {
record.native_result = payment["status"].as_str().map(str::to_owned);
journal.save_buyer(&record)?;
}
}
record = journal.retry_native(&record.binding.id)?;
}
anyhow::ensure!((!record.native_retired || matches!(action,"status"|"cancel"|"download")) && (!record.native_retired || !params.external_exposure),"This native invoice was retired before changing payment method; recover the replacement purchase");
if action == "pay" {
anyhow::ensure!(
params.operation_id.is_some(),
"Original invoice operation required for native payment"
);
return crate::content_lightning::drive_native(
&self.config.data_dir,
journal,
&record.binding.id,
&super::lnd::external_invoice::NativeNode(self),
)
.await;
}
record.external_exposure |= params.external_exposure;
journal.save_buyer(&record)?;
drop(journal);
// Native-only local FAILED never cancels an externally exposed invoice.
// The seller terminal state is authoritative regardless of UI receipt loss.
let operation = Operation {
binding: record.binding.clone(),
action: if action == "retry" {
"create".into()
} else {
action.into()
},
};
let response = crate::fips::dial::PeerRequest::new(Some(&fips), &params.onion, ROUTE)
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(if action == "download" {
900
} else {
45
}))
.send_content_json(&self.config.data_dir, &peer.did, &operation)
.await;
let mut response = match response {
Ok((r, _)) => r,
Err(_) => {
return Ok(
serde_json::json!({"state":"unknown","operation_id":record.binding.id,"recovery_required":true,"error":"The original invoice request is saved on this node. Recover it; no replacement invoice was requested."}),
)
}
};
anyhow::ensure!(
response.status().is_success(),
"Seller could not resolve original invoice; recover operation {}",
record.binding.id
);
let journal = Journal::open(&self.config.data_dir).await?;
if action == "download" {
let mut paid = record
.last
.clone()
.context("Original invoice metadata missing; recover it first")?;
let source = paid
.source
.clone()
.context("Original invoice snapshot missing")?;
anyhow::ensure!(
response.content_length() == Some(source.size),
"Original invoice file length changed"
);
paid.state = Phase::Settled;
paid.can_switch_method = false;
record.last = Some(paid);
journal.save_buyer(&record)?;
drop(journal);
let stream = crate::content_purchase_download::verified_stream(
response.bytes_stream(),
source.sha256,
source.size,
);
let owned = crate::content_owned::record_purchase_stream(
&self.config.data_dir,
crate::content_owned::OwnedItem {
onion: params.onion,
content_id: params.content_id,
filename: source.filename,
mime_type: source.mime_type,
size_bytes: source.size,
paid_sats: record.binding.price_sats,
ecash_backend: "lightning".into(),
purchased_at: chrono::Utc::now().to_rfc3339(),
download_complete: false,
},
Box::pin(stream),
Some(source.size),
)
.await?;
return Ok(
serde_json::json!({"owned":true,"owned_content_id":owned.content_id,"mime_type":owned.mime_type}),
);
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
bytes.len() + chunk.len() <= 16384,
"Invoice response too large"
);
bytes.extend_from_slice(&chunk)
}
let status: Status = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
status.binding == record.binding
&& status.source.is_some()
&& status.payment_hash.len() == 64
&& status.payment_hash.bytes().all(|b| b.is_ascii_hexdigit())
&& status.can_switch_method == (status.state == Phase::CanceledUnpaid),
"Seller invoice binding changed"
);
if let Some(bolt11) = &status.bolt11 {
let invoice: lightning_invoice::Bolt11Invoice =
bolt11.parse().context("Seller invoice is invalid")?;
invoice.check_signature()?;
anyhow::ensure!(
invoice.payment_hash().to_string() == status.payment_hash
&& invoice.amount_milli_satoshis()
== record.binding.price_sats.checked_mul(1000),
"Invoice hash or amount differs from saved purchase"
);
}
if let Some(previous) = &record.last {
anyhow::ensure!(
previous.payment_hash == status.payment_hash
&& previous.source == status.source
&& previous
.bolt11
.as_ref()
.is_none_or(|v| status.bolt11.as_ref() == Some(v)),
"Original invoice replaced"
);
}
record.last = Some(status.clone());
journal.save_buyer(&record)?;
Ok(
serde_json::json!({"operation_id":record.binding.id,"price_sats":record.binding.price_sats,"payment_hash":status.payment_hash,"bolt11":if record.external_exposure{status.bolt11}else{None},"state":match status.state{Phase::Settled=>"settled",Phase::CanceledUnpaid=>"canceled",Phase::Issued=>"open",Phase::Prepared=>"prepared",Phase::Dispatched=>"unknown",Phase::CancelRequested=>"cancel_requested"},"paid":status.state==Phase::Settled,"can_switch_method":status.can_switch_method,"cancel_supported":true,"external_exposure":record.external_exposure}),
)
}
}
impl RpcHandler {
/// Caller holds content_payment_admission before entering any rail journal.
pub(super) async fn ensure_invoice_allows_other_rail(
&self,
buyer: &str,
seller: &str,
content: &str,
) -> Result<()> {
let journal = Journal::open(&self.config.data_dir).await?;
if let Some(mut record) = journal.buyer_for(buyer, seller, content)? {
anyhow::ensure!(record.native_replacement.is_none(),
"An explicit native retry is being recovered; recover its replacement operation first");
anyhow::ensure!(
!record.external_exposure,
"An externally payable invoice remains unresolved; cancel or recover it first"
);
let status = record
.last
.clone()
.context("Original invoice creation is unresolved; recover it first")?;
anyhow::ensure!(
status.state != Phase::Settled,
"Original Lightning purchase is paid; recover its file"
);
// A local terminal failure can release only a never-exposed native
// attempt. This check runs under the same outer lock as QR exposure.
anyhow::ensure!(
record.native_dispatched,
"Original invoice has not been canceled; cancel it before replacing the method"
);
if record.native_result.as_deref() != Some("failed") {
let payment = self
.handle_lnd_paymentstatus(Some(
serde_json::json!({"payment_hash":status.payment_hash}),
))
.await?;
anyhow::ensure!(
payment["status"] == "failed",
"Original native Lightning attempt remains unresolved"
);
}
record.native_result = Some("failed".into());
record.native_retired = true;
journal.save_buyer(&record)?;
}
Ok(())
}
}
+26 -108
View File
@@ -272,8 +272,28 @@ impl RpcHandler {
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Omitted fees target the next block; explicit slower/custom choices win.
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(&params)?;
// Fee control: either a confirmation target or an explicit fee rate
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
if target_conf.is_some() && sat_per_vbyte.is_some() {
return Err(anyhow::anyhow!(
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
));
}
if let Some(tc) = target_conf {
if !(1..=1008).contains(&tc) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
}
if let Some(rate) = sat_per_vbyte {
if !(1..=5000).contains(&rate) {
return Err(anyhow::anyhow!(
"Invalid sat_per_vbyte: must be between 1 and 5000"
));
}
}
info!(
peer = pubkey,
@@ -453,7 +473,6 @@ impl RpcHandler {
));
}
let fee_query = close_channel_fee_query(&params)?;
let force = params
.get("force")
.and_then(|v| v.as_bool())
@@ -479,11 +498,13 @@ impl RpcHandler {
.build()
.context("Failed to create streaming HTTP client")?;
let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]);
let url = format!(
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
parts[0], parts[1], force
);
let mut resp = client
.delete(&url)
.query(&fee_query)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -551,106 +572,3 @@ impl RpcHandler {
}
}
}
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
/// With neither parameter LND uses a lax target; keep legacy clients on our
/// explicit next-block target rather than silently accepting that default.
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
let force = match params.get("force") {
None | Some(serde_json::Value::Null) => false,
Some(value) => value
.as_bool()
.ok_or_else(|| anyhow::anyhow!("force must be a boolean"))?,
};
let integer = |key: &str, max: u64| -> Result<Option<u64>> {
match params.get(key) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(value) => {
let n = value
.as_u64()
.ok_or_else(|| anyhow::anyhow!("{key} must be a positive whole number"))?;
anyhow::ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
Ok(Some(n))
}
}
};
let target = integer("target_conf", 1008)?;
let rate = integer("sat_per_vbyte", 5000)?;
anyhow::ensure!(
target.is_none() || rate.is_none(),
"Specify either target_conf or sat_per_vbyte, not both"
);
anyhow::ensure!(
!force || (target.is_none() && rate.is_none()),
"Closing fee selection requires a cooperative close"
);
let mut query = vec![("force", force.to_string())];
if !force {
if let Some(rate) = rate {
query.push(("sat_per_vbyte", rate.to_string()));
} else {
query.push((
"target_conf",
target
.unwrap_or(super::fee_policy::DEFAULT_TARGET as u64)
.to_string(),
));
}
}
Ok(query)
}
#[cfg(test)]
mod close_fee_tests {
use super::*;
#[test]
fn close_fee_query_forwards_presets_custom_and_legacy_default() {
for target in [1, 3, 6, 1008] {
assert_eq!(
close_channel_fee_query(&serde_json::json!({"target_conf":target})).unwrap(),
vec![
("force", "false".into()),
("target_conf", target.to_string())
]
);
}
for rate in [1, 25, 5000] {
let query =
close_channel_fee_query(&serde_json::json!({"sat_per_vbyte":rate})).unwrap();
let request = reqwest::Client::new()
.delete("http://localhost/v1/channels/test/0")
.query(&query)
.build()
.unwrap();
assert_eq!(request.method(), reqwest::Method::DELETE);
assert_eq!(
request.url().query(),
Some(format!("force=false&sat_per_vbyte={rate}").as_str())
);
}
assert_eq!(
close_channel_fee_query(&serde_json::json!({})).unwrap(),
vec![("force", "false".into()), ("target_conf", "1".into())]
);
assert_eq!(
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
vec![("force", "true".into())]
);
}
#[test]
fn malformed_or_conflicting_close_fees_fail_before_wallet_access() {
for params in [
serde_json::json!({"target_conf":1,"sat_per_vbyte":2}),
serde_json::json!({"force":true,"target_conf":1}),
serde_json::json!({"force":"false"}),
serde_json::json!({"target_conf":0}),
serde_json::json!({"target_conf":1009}),
serde_json::json!({"sat_per_vbyte":5001}),
serde_json::json!({"sat_per_vbyte":-1}),
serde_json::json!({"sat_per_vbyte":1.5}),
serde_json::json!({"sat_per_vbyte":"25"}),
] {
assert!(close_channel_fee_query(&params).is_err(), "{params}");
}
}
}
@@ -1,201 +0,0 @@
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
}
}
@@ -1,921 +0,0 @@
//! WalletKit BumpFee is CPFP for new wallet outputs, RBF only for sweeper inputs.
//! Never feed an ordinary payment input to it and call that a replacement.
use super::LND_REST_BASE_URL;
use crate::api::rpc::RpcHandler;
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{collections::HashMap, path::Path, sync::LazyLock};
use tokio::{io::AsyncWriteExt, sync::Mutex};
static QUOTES: LazyLock<Mutex<HashMap<String, Quote>>> = LazyLock::new(Default::default);
// Serialize check/register/persist across dashboard clients. The create_new receipt
// additionally survives process restarts and prevents retries of ambiguous results.
static SUBMIT: Mutex<()> = Mutex::const_new(());
const QUOTE_SECONDS: u64 = 60;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
struct Plan {
txid: String,
method: String,
input_txid: String,
input_index: u32,
parent_txid: String,
recipient_sats: u64,
rate_sat_vb: u64,
current_fee_sats: u64,
additional_fee_sats: u64,
total_fee_sats: u64,
budget_sats: u64,
input_sats: u64,
parent_vsize: u64,
sweep_vsize_bound: u64,
tip: String,
}
#[derive(Clone, Serialize, Deserialize)]
struct Quote {
quote_id: String,
expires_at: u64,
custom_rate: Option<u64>,
#[serde(flatten)]
plan: Plan,
}
#[derive(Serialize, Deserialize)]
struct Operation {
quote: Quote,
status: String,
message: String,
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn number(v: &Value) -> Result<u64> {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.context("Missing or invalid wallet amount")
}
fn txid_param(p: &Value) -> Result<String> {
let s = p["txid"].as_str().context("Missing transaction ID")?;
ensure!(
s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid transaction ID"
);
Ok(s.to_ascii_lowercase())
}
fn btc_sats(v: &Value) -> Result<u64> {
let n = v.as_f64().context("Missing Bitcoin fee")? * 100_000_000.0;
ensure!(
n.is_finite() && n >= 0.0 && n <= 2_100_000_000_000_000.0,
"Invalid Bitcoin fee"
);
Ok(n.round() as u64)
}
fn outpoint_matches(v: &Value, txid: &str, index: u32) -> bool {
v["txid_str"].as_str() == Some(txid) && v["output_index"].as_u64() == Some(index as u64)
}
fn array<'a>(v: &'a Value, key: &str) -> Result<&'a Vec<Value>> {
v[key]
.as_array()
.with_context(|| format!("Missing wallet field: {key}"))
}
fn sweep_size(output: &Value) -> Result<u64> {
// One native input, one wallet taproot output, including signature rounding.
match output["output_type"].as_str() {
Some("SCRIPT_TYPE_WITNESS_V1_TAPROOT") => Ok(112),
Some("SCRIPT_TYPE_WITNESS_V0_PUBKEY_HASH") => Ok(123),
_ => bail!("This output type is not supported for fee bumping yet"),
}
}
fn fee_budget(
rate: u64,
parent_size: u64,
parent_fee: u64,
size: u64,
old_fee: u64,
relay: u64,
input: u64,
) -> Result<u64> {
ensure!(
(1..=5000).contains(&rate),
"Fee rate must be a whole number from 1 to 5000 sat/vB"
);
ensure!(
parent_size <= 100_000 && size <= 100_000 && relay <= 5000,
"Unsupported package size or relay fee"
);
let required = rate * (parent_size + size);
let mut budget = required.saturating_sub(parent_fee).max(relay * size);
if old_fee > 0 {
budget = budget.max(old_fee + relay * size + 1);
}
ensure!(budget > old_fee, "Choose a higher fee rate");
// Conservative dust buffer; never attach unrelated wallet inputs to fund fees.
ensure!(
budget.checked_add(1000).is_some_and(|v| v <= input),
"Not enough wallet change for this fee; choose a lower rate"
);
Ok(budget)
}
/// Find the highest rate that fits the already selected wallet output. This is
/// only a quote-time calculation: it never asks LND to reserve or spend the
/// output. Keeping it here lets the caller give an actionable answer when the
/// requested target is too expensive.
fn highest_affordable_rate(
requested: u64,
parent_size: u64,
parent_fee: u64,
size: u64,
old_fee: u64,
relay: u64,
input: u64,
) -> Option<u64> {
if requested <= 1 {
return None;
}
let mut low = 1;
let mut high = requested.saturating_sub(1);
let mut best = None;
while low <= high {
let mid = low + (high - low) / 2;
if fee_budget(mid, parent_size, parent_fee, size, old_fee, relay, input).is_ok() {
best = Some(mid);
low = mid.saturating_add(1);
} else {
high = mid.saturating_sub(1);
}
}
best
}
fn quote_budget_error(
requested: u64,
parent_size: u64,
parent_fee: u64,
size: u64,
old_fee: u64,
relay: u64,
input: u64,
) -> anyhow::Error {
let available = input.saturating_sub(1000);
let suggestion = highest_affordable_rate(
requested,
parent_size,
parent_fee,
size,
old_fee,
relay,
input,
)
.map(|rate| format!(" Try {rate} sat/vB or lower."))
.unwrap_or_else(|| " No fee rate can currently fit this output.".into());
anyhow::anyhow!(
"Not enough wallet change for {requested} sat/vB: at most {available} sats is spendable for this bump.{suggestion}"
)
}
async fn lnd(
client: &reqwest::Client,
macaroon: &str,
path: &str,
body: Option<Value>,
) -> Result<Value> {
let url = format!("{LND_REST_BASE_URL}{path}");
let req = match body {
Some(v) => client.post(url).json(&v),
None => client.get(url),
};
let response = req
.header("Grpc-Metadata-macaroon", macaroon)
.send()
.await?;
let status = response.status();
let value: Value = response.json().await.context("Invalid LND response")?;
ensure!(
status.is_success() && value.get("code").is_none(),
"{}",
value["message"].as_str().unwrap_or("LND request failed")
);
Ok(value)
}
fn validate_quote(quote: &Quote, fresh: &Plan, timestamp: u64) -> Result<()> {
ensure!(
quote.expires_at > timestamp && quote.plan == *fresh,
"Transaction or fees changed; review a fresh quote"
);
Ok(())
}
fn bump_body(plan: &Plan) -> Value {
json!({"outpoint":{"txid_str":plan.input_txid,"output_index":plan.input_index},
"sat_per_vbyte":plan.rate_sat_vb.to_string(), "budget":plan.budget_sats.to_string(),
"deadline_delta":1, "immediate":true})
}
async fn reserve(path: &Path, op: &Operation) -> Result<()> {
let parent = path.parent().context("Invalid operation path")?;
tokio::fs::create_dir_all(parent).await?;
let mut f = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.await
.context("A bump already exists for this transaction; check its status")?;
f.write_all(&serde_json::to_vec(op)?).await?;
f.sync_all().await?;
// Sync directory entry too: a crash must not make a submitted operation vanish.
tokio::fs::File::open(parent).await?.sync_all().await?;
Ok(())
}
// Only a recorded Archy CPFP with one owned input and no external outputs may
// be folded into a payment. Labels and a fee-sized delta alone are not evidence.
fn fee_child_matches(tx: &Value, plan: &Plan) -> bool {
let input = format!("{}:{}", plan.input_txid, plan.input_index);
let amount = tx["amount"]
.as_i64()
.or_else(|| tx["amount"].as_str()?.parse().ok());
let fee = number(&tx["total_fees"])
.ok()
.and_then(|n| i64::try_from(n).ok());
tx["tx_hash"]
.as_str()
.is_some_and(|id| id.len() == 64 && id.bytes().all(|c| c.is_ascii_hexdigit()))
&& amount
.zip(fee)
.is_some_and(|(amount, fee)| fee > 0 && amount == -fee)
&& tx["previous_outpoints"].as_array().is_some_and(|inputs| {
inputs.len() == 1
&& inputs[0]["outpoint"] == input
&& inputs[0]["is_our_output"] == true
})
&& tx["output_details"].as_array().is_some_and(|outputs| {
!outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true)
})
}
impl RpcHandler {
pub(super) async fn group_fee_bump_history(
&self,
raw: &[Value],
normalized: &mut Vec<Value>,
client: &reqwest::Client,
) {
let mut hidden = std::collections::HashSet::new();
for parent in normalized.iter_mut() {
if parent["direction"] != "outgoing" {
continue;
}
let Some(id) = parent["tx_hash"].as_str().map(str::to_owned) else {
continue;
};
if id.len() != 64 || !id.bytes().all(|c| c.is_ascii_hexdigit()) {
continue;
}
let path = self
.config
.data_dir
.join("wallet/fee-bumps")
.join(format!("{id}.json"));
let Ok(bytes) = tokio::fs::read(path).await else {
continue;
};
let Ok(op) = serde_json::from_slice::<Operation>(&bytes) else {
continue;
};
let plan = &op.quote.plan;
if plan.method != "cpfp"
|| plan.txid != id
|| plan.parent_txid != id
|| plan.input_txid != id
{
continue;
}
let candidates: Vec<_> = raw
.iter()
.filter(|tx| fee_child_matches(tx, plan))
.collect();
let mut active = Vec::new();
for child in &candidates {
let child_id = child["tx_hash"].as_str().unwrap();
if child["num_confirmations"].as_i64().unwrap_or(0) > 0
|| self
.bitcoin_rpc_call::<Value>(client, "getmempoolentry", &[json!(child_id)])
.await
.is_ok()
{
active.push(*child);
}
}
// Ambiguous or unavailable chain state must not hide wallet history.
if active.len() != 1 {
continue;
}
let current = active[0];
parent["bump_fee_sats"] = json!(number(&current["total_fees"]).unwrap());
parent["fee_bump_txid"] = current["tx_hash"].clone();
parent["fee_bump_confirmations"] = current["num_confirmations"].clone();
parent["fee_bump_history"] = json!(candidates.iter().map(|child| {
let child_id = child["tx_hash"].as_str().unwrap();
hidden.insert(child_id.to_owned());
json!({"tx_hash":child_id,"fee_sats":number(&child["total_fees"]).unwrap(),
"status":if child["tx_hash"] != current["tx_hash"] { "replaced" }
else if child["num_confirmations"].as_i64().unwrap_or(0) > 0 { "confirmed" } else { "mempool" }})
}).collect::<Vec<_>>());
}
normalized.retain(|tx| !tx["tx_hash"].as_str().is_some_and(|id| hidden.contains(id)));
}
async fn bump_plan(&self, txid: &str, custom_rate: Option<u64>) -> Result<Plan> {
let (client, macaroon) = self.lnd_client().await?;
let info = lnd(&client, &macaroon, "/v1/getinfo", None).await?;
ensure!(
info["synced_to_chain"] == true,
"Wait for the wallet to finish syncing"
);
let version = info["version"]
.as_str()
.context("LND version is unavailable")?;
let mut parts = version.trim_start_matches('v').split('.');
let major: u32 = parts
.next()
.unwrap_or("")
.parse()
.context("Invalid LND version")?;
let minor: u32 = parts
.next()
.unwrap_or("")
.parse()
.context("Invalid LND version")?;
ensure!(
major > 0 || minor >= 21,
"This fee-bump interface requires LND 0.21 or newer"
);
let history = lnd(&client, &macaroon, "/v1/transactions", None).await?;
let txs = array(&history, "transactions")?;
let tx = txs
.iter()
.find(|t| t["tx_hash"] == txid)
.context("Transaction is not in this wallet")?;
ensure!(
tx["num_confirmations"].as_i64() == Some(0),
"This transaction is no longer pending"
);
let entry: Value = self
.bitcoin_rpc_call(&client, "getmempoolentry", &[json!(txid)])
.await
.context("Transaction is not currently in the node's mempool")?;
ensure!(
number(&entry["descendantcount"])? == 1,
"This transaction already has a child; open the child's Bump options instead"
);
let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?;
let sweeps = array(&pending, "pending_sweeps")?;
let published = lnd(
&client,
&macaroon,
"/v2/wallet/sweeps?verbose=false&start_height=-1",
None,
)
.await?;
let is_sweep = published["transaction_ids"]["transaction_ids"]
.as_array()
.is_some_and(|ids| ids.iter().any(|id| id == txid));
let outputs = array(tx, "output_details")?;
ensure!(
tx["amount"]
.as_str()
.and_then(|v| v.parse::<i64>().ok())
.or_else(|| tx["amount"].as_i64())
.is_some_and(|v| v < 0),
"Bump is available for outgoing payments and wallet fee sweeps"
);
let (
method,
input_txid,
input_index,
input_sats,
parent_txid,
parent_size,
parent_fee,
old_fee,
size,
recipient_sats,
) = if is_sweep {
// Only a simple wallet CPFP sweep is replaceable here. Anchor/HTLC,
// batched sweeps and arbitrary signed payments need different previews.
let raw: Value = self
.bitcoin_rpc_call(&client, "getrawtransaction", &[json!(txid), json!(true)])
.await?;
let inputs = array(&raw, "vin")?;
ensure!(
inputs.len() == 1 && outputs.len() == 1 && outputs[0]["is_our_address"] == true,
"RBF for batched or channel sweeps is not supported here yet"
);
let input_txid = inputs[0]["txid"]
.as_str()
.context("Missing sweep input")?
.to_string();
let index = u32::try_from(number(&inputs[0]["vout"])?)?;
ensure!(
sweeps.len() == 1 && outpoint_matches(&sweeps[0]["outpoint"], &input_txid, index),
"RBF is unavailable while other wallet sweeps are active"
);
let parent = txs
.iter()
.find(|t| t["tx_hash"] == input_txid)
.context("Sweep parent is unavailable")?;
let parent_output = array(parent, "output_details")?
.iter()
.find(|o| {
number(&o["output_index"]).ok() == Some(index as u64)
&& o["is_our_address"] == true
})
.context("RBF requires a wallet-owned change input")?;
let parent_entry: Value = self
.bitcoin_rpc_call(&client, "getmempoolentry", &[json!(input_txid)])
.await
.context("Only unconfirmed CPFP sweep replacements are supported here")?;
ensure!(
number(&parent_entry["ancestorcount"])? == 1
&& number(&parent_entry["descendantcount"])? == 2,
"Complex sweep package cannot be quoted safely"
);
let recipients = recipient_amount(parent)?;
(
"rbf",
input_txid.clone(),
index,
number(&parent_output["amount"])?,
input_txid,
number(&parent_entry["vsize"])?,
btc_sats(&parent_entry["fees"]["base"])?,
btc_sats(&entry["fees"]["base"])?,
sweep_size(parent_output)?.max(number(&entry["vsize"])?),
recipients,
)
} else {
ensure!(
sweeps.is_empty(),
"Another wallet sweep is active; wait for it before creating a CPFP bump"
);
ensure!(
number(&entry["ancestorcount"])? == 1,
"Fee bumping a chain of unconfirmed payments is not supported yet"
);
let unspent = lnd(
&client,
&macaroon,
"/v2/wallet/utxos",
Some(json!({"unconfirmed_only":true})),
)
.await?;
let utxos = array(&unspent, "utxos")?;
let leases = lnd(
&client,
&macaroon,
"/v2/wallet/utxos/leases",
Some(json!({})),
)
.await?;
let locked = array(&leases, "locked_utxos")?;
let output = outputs
.iter()
.filter(|o| o["is_our_address"] == true && sweep_size(o).is_ok())
.filter(|o| {
number(&o["output_index"]).ok().is_some_and(|i| {
utxos
.iter()
.any(|u| outpoint_matches(&u["outpoint"], txid, i as u32))
&& !locked
.iter()
.any(|u| outpoint_matches(&u["outpoint"], txid, i as u32))
})
})
.max_by_key(|o| number(&o["amount"]).unwrap_or(0))
.context(
"RBF is unavailable for this payment. CPFP needs spendable wallet-owned change",
)?;
let index = u32::try_from(number(&output["output_index"])?)?;
let available: Value = self
.bitcoin_rpc_call(
&client,
"gettxout",
&[json!(txid), json!(index), json!(true)],
)
.await?;
ensure!(
available.is_object()
&& number(&available["confirmations"])? == 0
&& btc_sats(&available["value"])? == number(&output["amount"])?,
"Change is no longer available"
);
(
"cpfp",
txid.to_string(),
index,
number(&output["amount"])?,
txid.to_string(),
number(&entry["vsize"])?,
btc_sats(&entry["fees"]["base"])?,
0,
sweep_size(output)?,
recipient_amount(tx)?,
)
};
let mempool: Value = self
.bitcoin_rpc_call(&client, "getmempoolinfo", &[])
.await?;
let relay = btc_sats(&mempool["incrementalrelayfee"])?
.div_ceil(1000)
.max(1);
let floor = btc_sats(&mempool["mempoolminfee"])?
.max(btc_sats(&mempool["minrelaytxfee"])?)
.div_ceil(1000)
.max(1);
let rate = match custom_rate {
Some(rate) => {
ensure!(
rate >= floor,
"Custom rate is below the current mempool minimum"
);
rate
}
None => {
let estimate = lnd(&client, &macaroon, "/v2/wallet/estimatefee/1", None).await?;
number(&estimate["sat_per_kw"])?.div_ceil(250).max(floor)
}
};
let budget = fee_budget(
rate,
parent_size,
parent_fee,
size,
old_fee,
relay.max(floor),
input_sats,
)
.map_err(|error| {
if error.to_string().contains("Not enough wallet change") {
quote_budget_error(
rate,
parent_size,
parent_fee,
size,
old_fee,
relay.max(floor),
input_sats,
)
} else {
error
}
})?;
let tip: String = self
.bitcoin_rpc_call(&client, "getbestblockhash", &[])
.await?;
Ok(Plan {
txid: txid.to_string(),
method: method.into(),
input_txid,
input_index,
parent_txid,
recipient_sats,
rate_sat_vb: rate,
current_fee_sats: parent_fee + old_fee,
additional_fee_sats: budget - old_fee,
total_fee_sats: parent_fee + budget,
budget_sats: budget,
input_sats,
parent_vsize: parent_size,
sweep_vsize_bound: size,
tip,
})
}
pub(in crate::api::rpc) async fn handle_lnd_bump_quote(
&self,
params: Option<Value>,
) -> Result<Value> {
let p = params.unwrap_or_default();
let txid = txid_param(&p)?;
let path = self
.config
.data_dir
.join("wallet/fee-bumps")
.join(format!("{txid}.json"));
ensure!(
!path.try_exists()?,
"A bump was already submitted for this transaction. Check its status"
);
let custom = p
.get("sat_per_vbyte")
.map(|v| v.as_u64().context("Custom rate must be a whole number"))
.transpose()?;
if let Some(rate) = custom {
ensure!(
(1..=5000).contains(&rate),
"Custom rate must be 1–5000 sat/vB"
);
}
let plan = self.bump_plan(&txid, custom).await?;
let quote = Quote {
quote_id: uuid::Uuid::new_v4().to_string(),
expires_at: now() + QUOTE_SECONDS,
custom_rate: custom,
plan,
};
let mut quotes = QUOTES.lock().await;
quotes.retain(|_, q| q.expires_at > now());
ensure!(quotes.len() < 128, "Too many fee quotes; try again shortly");
quotes.insert(quote.quote_id.clone(), quote.clone());
Ok(serde_json::to_value(quote)?)
}
pub(in crate::api::rpc) async fn handle_lnd_bump_submit(
&self,
params: Option<Value>,
) -> Result<Value> {
let p = params.unwrap_or_default();
let txid = txid_param(&p)?;
let _guard = SUBMIT.lock().await;
let path = self
.config
.data_dir
.join("wallet/fee-bumps")
.join(format!("{txid}.json"));
if path.try_exists()? {
return self
.handle_lnd_bump_status(Some(json!({"txid":txid})))
.await;
}
let id = p["quote_id"]
.as_str()
.context("A reviewed fee quote is required")?;
let quote = QUOTES
.lock()
.await
.get(id)
.cloned()
.context("Quote expired; review the fee again")?;
ensure!(
quote.plan.txid == txid && quote.expires_at > now(),
"Quote expired; review the fee again"
);
let fresh = self.bump_plan(&txid, quote.custom_rate).await?;
validate_quote(&quote, &fresh, now())?;
let op = Operation {
quote: quote.clone(),
status: "unknown".into(),
message: "Submission recorded; checking the wallet. Do not submit another bump.".into(),
};
reserve(&path, &op).await?;
QUOTES.lock().await.remove(id);
let (client, macaroon) = self.lnd_client().await?;
// At a one-block deadline LND may spend ALL this explicitly previewed
// budget. It is always below input value, so no extra funding is requested.
let result = lnd(
&client,
&macaroon,
"/v2/wallet/bumpfee",
Some(bump_body(&fresh)),
)
.await;
// Keep the write-ahead record even for an RPC error: a lost response can
// conceal an accepted bump. Status reconciles from wallet/mempool evidence.
match result {
Ok(_) => Ok(
json!({"status":"registered", "message":"Bump registered with the wallet. Waiting for broadcast.", "quote":quote}),
),
Err(_) => Ok(
json!({"status":"unknown", "message":"The wallet response was not confirmed. Check status; do not submit again.", "quote":quote}),
),
}
}
pub(in crate::api::rpc) async fn handle_lnd_bump_status(
&self,
params: Option<Value>,
) -> Result<Value> {
let txid = txid_param(&params.unwrap_or_default())?;
let path = self
.config
.data_dir
.join("wallet/fee-bumps")
.join(format!("{txid}.json"));
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(json!({"status":"none"}))
}
Err(e) => return Err(e.into()),
};
let op: Operation = serde_json::from_slice(&bytes)
.context("Bump receipt needs recovery; do not resubmit")?;
let (client, macaroon) = self.lnd_client().await?;
let history = lnd(&client, &macaroon, "/v1/transactions", None).await?;
let plan = &op.quote.plan;
let input = format!("{}:{}", plan.input_txid, plan.input_index);
let mut candidates: Vec<&Value> = array(&history, "transactions")?
.iter()
.filter(|t| {
t["tx_hash"] != txid
&& t["output_details"].as_array().is_some_and(|outputs| {
!outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true)
})
&& t["previous_outpoints"]
.as_array()
.is_some_and(|inputs| inputs.iter().any(|i| i["outpoint"] == input))
})
.collect();
candidates.sort_by_key(|t| std::cmp::Reverse(number(&t["time_stamp"]).unwrap_or(0)));
for t in candidates {
let id = t["tx_hash"]
.as_str()
.context("Missing bump transaction ID")?;
let confirmed = t["num_confirmations"].as_i64().unwrap_or(0) > 0;
let accepted = if confirmed {
false
} else {
self.bitcoin_rpc_call::<Value>(&client, "getmempoolentry", &[json!(id)])
.await
.is_ok()
};
if confirmed || accepted {
return Ok(json!({"status":if confirmed {"confirmed"} else {"mempool"},
"message":if confirmed {"Fee bump confirmed."} else {"Fee bump accepted in the node's mempool; awaiting confirmation."},
"bump_txid":id,"confirmations":t["num_confirmations"],"actual_sweep_fee_sats":number(&t["total_fees"])?,"quote":op.quote}));
}
}
let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?;
let registered = array(&pending, "pending_sweeps")?.iter().any(|s| {
outpoint_matches(&s["outpoint"], &plan.input_txid, plan.input_index)
&& number(&s["budget"]).ok() == Some(plan.budget_sats)
&& number(&s["requested_sat_per_vbyte"]).ok() == Some(plan.rate_sat_vb)
});
Ok(
json!({"status":if registered {"registered"} else {"unknown"},
"message":if registered {"Bump registered; waiting for a verified broadcast."} else {"Submission outcome is unknown. Do not submit again; check wallet status."}, "quote":op.quote}),
)
}
}
fn recipient_amount(tx: &Value) -> Result<u64> {
array(tx, "output_details")?
.iter()
.filter(|o| o["is_our_address"] == false)
.try_fold(0u64, |sum, o| {
sum.checked_add(number(&o["amount"])?)
.context("Recipient amount overflow")
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_plan() -> Plan {
serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":161650,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap()
}
#[test]
fn history_requires_owned_simple_fee_only_child() {
let plan = sample_plan();
let tx = json!({"tx_hash":"b".repeat(64),"amount":"-200","total_fees":"200",
"previous_outpoints":[{"outpoint":"a:0","is_our_output":true}],
"output_details":[{"is_our_address":true}]});
assert!(fee_child_matches(&tx, &plan));
for bad in [
json!({"amount":"-201"}),
json!({"amount":"200"}),
json!({"total_fees":"0"}),
json!({"previous_outpoints":[{"outpoint":"a:1","is_our_output":true}]}),
json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":false}]}),
json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":true},{"outpoint":"c:0","is_our_output":true}]}),
json!({"output_details":[{"is_our_address":false}]}),
json!({"output_details":[]}),
json!({"tx_hash":"../../invalid"}),
] {
let mut changed = tx.clone();
for (key, value) in bad.as_object().unwrap() {
changed[key] = value.clone();
}
assert!(!fee_child_matches(&changed, &plan), "{bad}");
}
}
#[test]
fn stale_quotes_cannot_silently_change_approved_fee_or_transaction() {
let plan = sample_plan();
let q = Quote {
quote_id: "q".into(),
expires_at: 100,
custom_rate: None,
plan: plan.clone(),
};
assert!(validate_quote(&q, &plan, 99).is_ok());
assert!(validate_quote(&q, &plan, 100).is_err());
let mut changed = plan.clone();
changed.budget_sats += 1;
assert!(validate_quote(&q, &changed, 99).is_err());
changed = plan.clone();
changed.input_index += 1;
assert!(validate_quote(&q, &changed, 99).is_err());
changed = plan.clone();
changed.recipient_sats -= 1;
assert!(validate_quote(&q, &changed, 99).is_err());
changed = plan.clone();
changed.tip = "new block".into();
assert!(validate_quote(&q, &changed, 99).is_err());
}
#[test]
fn mutation_always_has_explicit_budget_and_does_not_send_a_second_payment() {
assert_eq!(
bump_body(&sample_plan()),
json!({"outpoint":{"txid_str":"a","output_index":0},"sat_per_vbyte":"3","budget":"618","deadline_delta":1,"immediate":true})
);
let mut rbf = sample_plan();
rbf.method = "rbf".into();
rbf.txid = "child".into();
// RBF uses the already-registered input, not the child's output.
assert_eq!(bump_body(&rbf)["outpoint"]["txid_str"], "a");
}
#[test]
fn outpoint_ownership_and_recipient_exclude_wallet_change() {
assert!(outpoint_matches(
&json!({"txid_str":"a","output_index":2}),
"a",
2
));
assert!(!outpoint_matches(
&json!({"txid_str":"b","output_index":2}),
"a",
2
));
assert!(!outpoint_matches(
&json!({"txid_str":"a","output_index":3}),
"a",
2
));
assert_eq!(recipient_amount(&json!({"output_details":[{"is_our_address":true,"amount":"21126"},{"is_our_address":false,"amount":"161650"}]})).unwrap(), 161650);
assert!(recipient_amount(
&json!({"output_details":[{"is_our_address":false,"amount":"bad"}]})
)
.is_err());
}
#[test]
fn cpfp_budget_covers_parent_and_preserves_change() {
assert_eq!(fee_budget(3, 142, 144, 112, 0, 1, 21126).unwrap(), 618);
assert!(fee_budget(5000, 142, 144, 112, 0, 1, 21126).is_err());
assert!(fee_budget(0, 142, 144, 112, 0, 1, 21126).is_err());
}
#[test]
fn unaffordable_quote_explains_spendable_change_and_viable_rate() {
let suggested = highest_affordable_rate(5000, 142, 144, 112, 0, 1, 21126);
assert_eq!(suggested, Some(79));
let error = quote_budget_error(5000, 142, 144, 112, 0, 1, 21126).to_string();
assert!(error.contains("at most 20126 sats is spendable"));
assert!(error.contains("Try 79 sat/vB or lower"));
}
#[test]
fn no_affordable_rate_is_reported_without_mutating_the_output() {
let error = quote_budget_error(10, 142, 144, 112, 9_000, 1, 10_000).to_string();
assert!(error.contains("at most 9000 sats is spendable"));
assert!(error.contains("No fee rate can currently fit this output"));
}
#[test]
fn rbf_pays_incremental_relay_cost_and_counts_only_extra_cost() {
let fee = fee_budget(3, 142, 144, 112, 650, 1, 21126).unwrap();
assert_eq!(fee, 763);
assert_eq!(fee - 650, 113);
}
#[test]
fn unsupported_outputs_and_malformed_ids_fail_closed() {
assert!(sweep_size(&json!({"output_type":"SCRIPT_TYPE_WITNESS_V0_SCRIPT_HASH"})).is_err());
assert!(txid_param(&json!({"txid":"../../file"})).is_err());
assert!(number(&json!(-1)).is_err());
assert!(btc_sats(&json!(-0.1)).is_err());
assert_eq!(btc_sats(&json!(0.00000650)).unwrap(), 650);
}
#[tokio::test]
async fn receipt_prevents_duplicate_submission_after_restart() {
let dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
let path = dir.join("receipt.json");
let plan: Plan = serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":1000,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap();
let op = Operation {
quote: Quote {
quote_id: "q".into(),
expires_at: now() + 60,
custom_rate: None,
plan,
},
status: "unknown".into(),
message: "pending".into(),
};
reserve(&path, &op).await.unwrap();
assert!(reserve(&path, &op).await.is_err());
let restored: Operation =
serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap();
assert_eq!(restored.quote.plan.budget_sats, 618);
tokio::fs::remove_dir_all(dir).await.unwrap();
}
}
@@ -1,120 +0,0 @@
//! Explicit on-chain fee choices retain priority; omitted choices target the next block.
use anyhow::{ensure, Context, Result};
use serde_json::Value;
pub(super) const DEFAULT_TARGET: i64 = 1;
pub(super) fn estimated_sat_per_vbyte(value: &Value) -> Result<u64> {
let per_kw = value["sat_per_kw"]
.as_u64()
.or_else(|| value["sat_per_kw"].as_str().and_then(|s| s.parse().ok()))
.context("Next-block fee estimate is unavailable")?;
let rate = per_kw.div_ceil(250);
ensure!(
(1..=5000).contains(&rate),
"Next-block fee estimate is outside supported bounds; choose an explicit fee"
);
Ok(rate)
}
pub(super) fn fee_options(params: &Value) -> Result<(Option<i64>, Option<i64>)> {
let integer = |key: &str, max: i64| -> Result<Option<i64>> {
match params.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => {
let n = value
.as_i64()
.with_context(|| format!("{key} must be a positive whole number"))?;
ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
Ok(Some(n))
}
}
};
let target = integer("target_conf", 1008)?;
let rate = integer("sat_per_vbyte", 5000)?;
ensure!(
target.is_none() || rate.is_none(),
"Specify either target_conf or sat_per_vbyte, not both"
);
Ok((
if rate.is_none() {
Some(target.unwrap_or(DEFAULT_TARGET))
} else {
None
},
rate,
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn estimates_round_up_and_missing_or_extreme_estimates_fail_closed() {
assert_eq!(
estimated_sat_per_vbyte(&json!({"sat_per_kw":"501"})).unwrap(),
3
);
assert_eq!(
estimated_sat_per_vbyte(&json!({"sat_per_kw":250})).unwrap(),
1
);
for v in [
json!({}),
json!({"sat_per_kw":0}),
json!({"sat_per_kw":-1}),
json!({"sat_per_kw":1250001}),
] {
assert!(estimated_sat_per_vbyte(&v).is_err());
}
}
#[test]
fn next_block_default_preserves_explicit_slower_and_custom_choices() {
assert_eq!(fee_options(&json!({})).unwrap(), (Some(1), None));
assert_eq!(
fee_options(&json!({"target_conf":null})).unwrap(),
(Some(1), None)
);
for target in [1, 3, 6, 144, 1008] {
assert_eq!(
fee_options(&json!({"target_conf":target})).unwrap(),
(Some(target), None)
);
}
for rate in [1, 17, 5000] {
assert_eq!(
fee_options(&json!({"sat_per_vbyte":rate})).unwrap(),
(None, Some(rate))
);
}
}
#[test]
fn malformed_explicit_fees_never_silently_become_fast() {
for value in [
json!(0),
json!(-1),
json!(1.5),
json!("6"),
json!(true),
json!({}),
json!(1009),
] {
assert!(fee_options(&json!({"target_conf":value})).is_err());
}
for value in [
json!(0),
json!(-1),
json!(1.5),
json!("6"),
json!(true),
json!(5001),
] {
assert!(fee_options(&json!({"sat_per_vbyte":value})).is_err());
}
assert!(fee_options(&json!({"target_conf":1,"sat_per_vbyte":2})).is_err());
}
}
-4
View File
@@ -1,10 +1,6 @@
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
+27 -58
View File
@@ -34,33 +34,6 @@ 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()
@@ -217,7 +190,33 @@ impl RpcHandler {
return Err(payment_error(msg));
}
let payment = body.get("result").unwrap_or(&body);
Ok(router_payment_outcome(payment, &decoded_hash, decoded_amt))
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,
}))
}
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
@@ -245,10 +244,6 @@ 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
@@ -407,9 +402,6 @@ impl RpcHandler {
}));
}
self.group_fee_bump_history(raw_txs, &mut transactions, &client)
.await;
// Sort by timestamp descending (most recent first)
transactions.sort_by(|a, b| {
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
@@ -570,29 +562,6 @@ 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!(

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